1114 lines
25 KiB
Go
1114 lines
25 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"charm.land/bubbles/v2/list"
|
||
"charm.land/bubbles/v2/textarea"
|
||
"charm.land/bubbles/v2/viewport"
|
||
tea "charm.land/bubbletea/v2"
|
||
"github.com/atotto/clipboard"
|
||
"github.com/charmbracelet/x/ansi"
|
||
lipgloss "charm.land/lipgloss/v2"
|
||
)
|
||
|
||
type message struct {
|
||
Role string `json:"role"`
|
||
Content string `json:"content"`
|
||
}
|
||
|
||
type modelRef struct {
|
||
provider string
|
||
model string
|
||
}
|
||
|
||
func (m modelRef) Title() string { return m.provider + "/" + m.model }
|
||
func (m modelRef) Description() string { return "" }
|
||
func (m modelRef) FilterValue() string { return m.provider + "/" + m.model }
|
||
|
||
type screenMode int
|
||
|
||
const (
|
||
screenChat screenMode = iota
|
||
screenModelPicker
|
||
screenSessionPicker
|
||
)
|
||
|
||
type chatbot struct {
|
||
screen screenMode
|
||
messages []message
|
||
viewport viewport.Model
|
||
textarea textarea.Model
|
||
modelList list.Model
|
||
sessList list.Model
|
||
ready bool
|
||
width int
|
||
height int
|
||
streaming bool
|
||
follow bool
|
||
respBuf strings.Builder
|
||
|
||
currentProvider string
|
||
currentModel string
|
||
providers map[string]ProviderConfig
|
||
styles Styles
|
||
err error
|
||
streamCh <-chan tea.Msg
|
||
|
||
history []string
|
||
historyIdx int
|
||
historyDft string
|
||
|
||
sessions []SessionData
|
||
sessionIdx int
|
||
|
||
hasAlternateKeys bool
|
||
animTick int
|
||
cancel context.CancelFunc
|
||
textareaHeight int
|
||
pasteBuffer string
|
||
selecting bool
|
||
selStartY int
|
||
selEndY int
|
||
selMode bool
|
||
selVisual bool
|
||
selCurY int
|
||
selMarkY int
|
||
}
|
||
|
||
var promptFrames []string
|
||
|
||
type animTickMsg struct{}
|
||
|
||
func tickAnim() tea.Cmd {
|
||
return tea.Tick(150*time.Millisecond, func(t time.Time) tea.Msg {
|
||
return animTickMsg{}
|
||
})
|
||
}
|
||
|
||
func newChatbot(providers map[string]ProviderConfig, sessions []SessionData, sty Styles) (*chatbot, error) {
|
||
ta := textarea.New()
|
||
ta.Placeholder = ""
|
||
ta.ShowLineNumbers = false
|
||
ta.CharLimit = 0
|
||
ta.KeyMap.InsertNewline.SetEnabled(true)
|
||
ta.Focus()
|
||
ta.Prompt = "⋮⋮⋮ "
|
||
ta.DynamicHeight = true
|
||
ta.MinHeight = 1
|
||
ta.MaxHeight = 10
|
||
ta.MaxWidth = 0
|
||
|
||
inputBg := sty.BgSubtle
|
||
inputFg := sty.Fg
|
||
|
||
bgStyle := lipgloss.NewStyle().Background(inputBg)
|
||
fgStyle := bgStyle.Foreground(inputFg)
|
||
|
||
taStyles := ta.Styles()
|
||
taStyles.Focused.Base = bgStyle
|
||
taStyles.Focused.Text = fgStyle
|
||
taStyles.Focused.CursorLine = fgStyle
|
||
taStyles.Focused.LineNumber = bgStyle
|
||
taStyles.Focused.EndOfBuffer = bgStyle
|
||
taStyles.Focused.Prompt = sty.PromptFocused
|
||
taStyles.Focused.Placeholder = bgStyle.Foreground(sty.Help.GetForeground())
|
||
taStyles.Blurred.Base = bgStyle
|
||
taStyles.Blurred.Text = fgStyle
|
||
taStyles.Blurred.CursorLine = fgStyle
|
||
taStyles.Blurred.LineNumber = bgStyle
|
||
taStyles.Blurred.EndOfBuffer = bgStyle
|
||
taStyles.Blurred.Prompt = sty.PromptBlurred
|
||
taStyles.Blurred.Placeholder = bgStyle.Foreground(sty.Help.GetForeground())
|
||
taStyles.Cursor.Color = sty.LogoGradFrom
|
||
taStyles.Cursor.Shape = tea.CursorBlock
|
||
taStyles.Cursor.Blink = true
|
||
ta.SetStyles(taStyles)
|
||
|
||
promptFrames = []string{
|
||
"·⋮⋮ ",
|
||
"⋮·⋮ ",
|
||
"⋮⋮· ",
|
||
}
|
||
|
||
vp := viewport.New(viewport.WithWidth(80), viewport.WithHeight(20))
|
||
|
||
dd := list.NewDefaultDelegate()
|
||
dd.Styles.SelectedTitle = lipgloss.NewStyle().
|
||
Border(lipgloss.ThickBorder(), false, false, false, true).
|
||
BorderForeground(sty.ModelPickerBorder).
|
||
Bold(true).
|
||
Padding(0, 0, 0, 1)
|
||
dd.Styles.SelectedDesc = dd.Styles.SelectedTitle
|
||
|
||
items := buildModelItems(providers)
|
||
if len(items) == 0 {
|
||
return nil, fmt.Errorf("no models available from any provider")
|
||
}
|
||
|
||
ml := list.New(items, dd, 40, 15)
|
||
ml.Title = "Select model (enter/esc, type to filter)"
|
||
ml.SetShowStatusBar(false)
|
||
ml.SetFilteringEnabled(true)
|
||
ml.Styles.Title = sty.Help
|
||
|
||
sl := list.New(nil, dd, 40, 15)
|
||
sl.Title = "Sessions (enter: switch, n: new, d: delete, esc: close)"
|
||
sl.SetShowStatusBar(false)
|
||
sl.SetFilteringEnabled(true)
|
||
sl.Styles.Title = sty.Help
|
||
|
||
ref := items[0].(modelRef)
|
||
|
||
if len(sessions) == 0 {
|
||
sessions = []SessionData{NewSessionData(ref.provider, ref.model)}
|
||
}
|
||
si := 0
|
||
cur := sessions[si]
|
||
var msgs []message
|
||
var hist []string
|
||
prov := cur.Provider
|
||
mod := cur.Model
|
||
if cur.Messages != nil {
|
||
msgs = cur.Messages
|
||
}
|
||
if cur.History != nil {
|
||
hist = cur.History
|
||
}
|
||
if _, ok := providers[prov]; !ok {
|
||
prov = ref.provider
|
||
}
|
||
if mod == "" {
|
||
mod = ref.model
|
||
}
|
||
|
||
cb := &chatbot{
|
||
screen: screenChat,
|
||
messages: msgs,
|
||
viewport: vp,
|
||
textarea: ta,
|
||
modelList: ml,
|
||
sessList: sl,
|
||
currentProvider: prov,
|
||
currentModel: mod,
|
||
providers: providers,
|
||
styles: sty,
|
||
historyIdx: -1,
|
||
history: hist,
|
||
sessions: sessions,
|
||
sessionIdx: si,
|
||
hasAlternateKeys: false,
|
||
follow: true,
|
||
}
|
||
|
||
return cb, nil
|
||
}
|
||
|
||
func buildModelItems(providers map[string]ProviderConfig) []list.Item {
|
||
var items []list.Item
|
||
for pid := range providers {
|
||
pid2 := pid
|
||
models, err := fetchModels(providers[pid].BaseURL)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
for _, m := range models {
|
||
items = append(items, modelRef{provider: pid2, model: m})
|
||
}
|
||
}
|
||
return items
|
||
}
|
||
|
||
func (m *chatbot) Init() tea.Cmd {
|
||
return nil
|
||
}
|
||
|
||
func (m *chatbot) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
switch msg := msg.(type) {
|
||
case tea.WindowSizeMsg:
|
||
return m.handleResize(msg)
|
||
|
||
case tea.KeyPressMsg:
|
||
return m.handleKey(msg)
|
||
|
||
case tea.KeyboardEnhancementsMsg:
|
||
m.hasAlternateKeys = msg.SupportsAlternateKeys()
|
||
return m, nil
|
||
|
||
case streamChunkMsg:
|
||
m.respBuf.WriteString(string(msg))
|
||
m.updateViewport()
|
||
return m, readNext(m.streamCh)
|
||
|
||
case streamDoneMsg:
|
||
m.streaming = false
|
||
m.streamCh = nil
|
||
m.textarea.Prompt = "⋮⋮⋮ "
|
||
content := m.respBuf.String()
|
||
m.respBuf.Reset()
|
||
if content != "" {
|
||
m.messages = append(m.messages, message{Role: "assistant", Content: content})
|
||
}
|
||
m.saveCurrentSession()
|
||
if m.cancel == nil {
|
||
m.messages = append(m.messages, message{Role: "system", Content: "Canceled"})
|
||
}
|
||
m.recalcLayout()
|
||
m.updateViewport()
|
||
return m, nil
|
||
|
||
case streamErrMsg:
|
||
m.streaming = false
|
||
m.streamCh = nil
|
||
m.textarea.Prompt = "⋮⋮⋮ "
|
||
m.err = msg.err
|
||
m.messages = append(m.messages, message{Role: "system", Content: msg.err.Error()})
|
||
m.recalcLayout()
|
||
m.updateViewport()
|
||
return m, nil
|
||
|
||
case animTickMsg:
|
||
if m.streaming {
|
||
m.animTick = (m.animTick + 1) % len(promptFrames)
|
||
m.textarea.Prompt = promptFrames[m.animTick]
|
||
return m, tickAnim()
|
||
}
|
||
return m, nil
|
||
|
||
case tea.PasteMsg:
|
||
content := msg.Content
|
||
lines := strings.Split(content, "\n")
|
||
if len(lines) <= 1 {
|
||
for _, r := range content {
|
||
m.textarea.InsertRune(r)
|
||
}
|
||
} else {
|
||
nonEmpty := 0
|
||
for _, l := range lines {
|
||
if strings.TrimSpace(l) != "" {
|
||
nonEmpty++
|
||
}
|
||
}
|
||
m.pasteBuffer = content
|
||
m.textarea.SetValue(fmt.Sprintf("[Pasted ~%d lines]", nonEmpty))
|
||
m.textarea.MoveToEnd()
|
||
m.recalcLayout()
|
||
}
|
||
return m, nil
|
||
|
||
case tea.MouseWheelMsg:
|
||
vp, _ := m.viewport.Update(msg)
|
||
m.viewport = vp
|
||
switch msg.Button {
|
||
case tea.MouseWheelUp:
|
||
m.follow = false
|
||
case tea.MouseWheelDown:
|
||
if m.viewport.AtBottom() {
|
||
m.follow = true
|
||
}
|
||
}
|
||
return m, nil
|
||
|
||
case tea.MouseClickMsg:
|
||
if msg.Button == tea.MouseLeft && !m.isLanding() && msg.Y > 0 && msg.Y <= m.viewport.Height() && msg.X < m.width-1 {
|
||
m.selecting = true
|
||
m.selStartY = msg.Y - 1 + m.viewport.YOffset()
|
||
m.selEndY = m.selStartY
|
||
}
|
||
return m, nil
|
||
|
||
case tea.MouseMotionMsg:
|
||
if m.selecting && !m.isLanding() && msg.Y > 0 && msg.Y <= m.viewport.Height() && msg.X < m.width-1 {
|
||
m.selEndY = msg.Y - 1 + m.viewport.YOffset()
|
||
}
|
||
return m, nil
|
||
|
||
case tea.MouseReleaseMsg:
|
||
if m.selecting {
|
||
m.selecting = false
|
||
if m.selStartY != m.selEndY {
|
||
content := m.buildViewportContent()
|
||
lines := strings.Split(content, "\n")
|
||
start, end := m.selStartY, m.selEndY
|
||
if start > end {
|
||
start, end = end, start
|
||
}
|
||
if start < 0 {
|
||
start = 0
|
||
}
|
||
if end >= len(lines) {
|
||
end = len(lines) - 1
|
||
}
|
||
selected := strings.Join(lines[start:end+1], "\n")
|
||
selected = ansi.Strip(selected)
|
||
selected = strings.TrimSpace(selected)
|
||
if selected != "" {
|
||
clipboard.WriteAll(selected)
|
||
}
|
||
}
|
||
}
|
||
return m, nil
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m *chatbot) handleResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) {
|
||
m.width = msg.Width
|
||
m.height = msg.Height
|
||
|
||
if !m.ready {
|
||
m.ready = true
|
||
vp := viewport.New(viewport.WithWidth(msg.Width-1), viewport.WithHeight(1))
|
||
m.viewport = vp
|
||
m.textarea.SetWidth(msg.Width - 6)
|
||
}
|
||
m.viewport.SetWidth(msg.Width - 1)
|
||
if m.isLanding() {
|
||
w := min(m.width*2/5, 200)
|
||
m.textarea.SetWidth(w - 6)
|
||
} else {
|
||
m.textarea.SetWidth(msg.Width - 8)
|
||
}
|
||
m.recalcLayout()
|
||
return m, nil
|
||
}
|
||
|
||
func (m *chatbot) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||
key := msg.String()
|
||
|
||
if key == "ctrl+c" {
|
||
m.saveCurrentSession()
|
||
return m, tea.Quit
|
||
}
|
||
|
||
if key == "esc" && m.streaming {
|
||
if m.cancel != nil {
|
||
m.cancel()
|
||
m.cancel = nil
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
if m.selMode {
|
||
return m.handleSelKey(key, msg)
|
||
}
|
||
|
||
if m.screen == screenModelPicker {
|
||
return m.handleModelPickerKey(key, msg)
|
||
}
|
||
if m.screen == screenSessionPicker {
|
||
return m.handleSessionPickerKey(key, msg)
|
||
}
|
||
|
||
if key == "ctrl+v" {
|
||
if !m.selMode {
|
||
m.selMode = true
|
||
m.selVisual = false
|
||
m.selCurY = m.viewport.YOffset()
|
||
m.selMarkY = -1
|
||
m.textarea.Blur()
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
if key == "enter" {
|
||
text := strings.TrimSpace(m.textarea.Value())
|
||
if text == "" {
|
||
return m, nil
|
||
}
|
||
|
||
if m.isLanding() {
|
||
m.newSessionInner()
|
||
}
|
||
|
||
if m.pasteBuffer != "" {
|
||
text = strings.TrimSpace(m.pasteBuffer)
|
||
m.pasteBuffer = ""
|
||
}
|
||
|
||
m.textarea.Reset()
|
||
m.history = append(m.history, text)
|
||
m.historyIdx = -1
|
||
|
||
m.messages = append(m.messages, message{Role: "user", Content: text})
|
||
m.saveCurrentSession()
|
||
m.follow = true
|
||
m.updateViewport()
|
||
|
||
m.streaming = true
|
||
m.respBuf.Reset()
|
||
m.animTick = 0
|
||
m.textarea.Prompt = "⋮⋮⋮ "
|
||
m.recalcLayout()
|
||
m.updateViewport()
|
||
|
||
api := make([]chatMessage, len(m.messages))
|
||
for i, msg := range m.messages {
|
||
api[i] = chatMessage{Role: msg.Role, Content: msg.Content}
|
||
}
|
||
prov := m.providers[m.currentProvider]
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
m.cancel = cancel
|
||
ch, cmd := startStream(ctx, api, m.currentModel, prov.BaseURL)
|
||
m.streamCh = ch
|
||
return m, tea.Batch(cmd, tickAnim())
|
||
}
|
||
|
||
if key == "ctrl+o" {
|
||
m.modelList.ResetFilter()
|
||
m.screen = screenModelPicker
|
||
return m, nil
|
||
}
|
||
|
||
if key == "ctrl+s" {
|
||
m.saveCurrentSession()
|
||
m.sessList.SetItems(buildSessionItems(m.sessions, m.sessionIdx))
|
||
m.sessList.ResetFilter()
|
||
m.screen = screenSessionPicker
|
||
return m, nil
|
||
}
|
||
|
||
if key == "ctrl+n" {
|
||
return m.newSession()
|
||
}
|
||
|
||
if key == "ctrl+w" {
|
||
return m.closeCurrentSession()
|
||
}
|
||
|
||
if key == "pgup" {
|
||
m.follow = false
|
||
m.viewport.ScrollUp(m.height / 2)
|
||
return m, nil
|
||
}
|
||
|
||
if key == "pgdown" {
|
||
m.viewport.ScrollDown(m.height / 2)
|
||
if m.viewport.AtBottom() {
|
||
m.follow = true
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
if key == "up" || key == "ctrl+p" {
|
||
if len(m.history) == 0 {
|
||
return m, nil
|
||
}
|
||
if m.historyIdx == -1 {
|
||
m.historyDft = m.textarea.Value()
|
||
m.historyIdx = len(m.history) - 1
|
||
} else if m.historyIdx > 0 {
|
||
m.historyIdx--
|
||
}
|
||
m.textarea.Reset()
|
||
m.textarea.SetValue(m.history[m.historyIdx])
|
||
m.textarea.MoveToEnd()
|
||
return m, nil
|
||
}
|
||
|
||
if key == "down" {
|
||
if m.historyIdx == -1 {
|
||
return m, nil
|
||
}
|
||
m.historyIdx++
|
||
if m.historyIdx >= len(m.history) {
|
||
m.historyIdx = -1
|
||
m.textarea.Reset()
|
||
if m.historyDft != "" {
|
||
m.textarea.SetValue(m.historyDft)
|
||
m.historyDft = ""
|
||
}
|
||
} else {
|
||
m.textarea.Reset()
|
||
m.textarea.SetValue(m.history[m.historyIdx])
|
||
}
|
||
m.textarea.MoveToEnd()
|
||
return m, nil
|
||
}
|
||
|
||
m.historyIdx = -1
|
||
prevH := m.textarea.Height()
|
||
ta, cmd := m.textarea.Update(msg)
|
||
m.textarea = ta
|
||
if ta.Height() != prevH {
|
||
m.recalcLayout()
|
||
}
|
||
return m, cmd
|
||
}
|
||
|
||
func (m *chatbot) handleModelPickerKey(key string, msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||
if key == "esc" {
|
||
m.screen = screenChat
|
||
return m, nil
|
||
}
|
||
if key == "enter" {
|
||
if item, ok := m.modelList.SelectedItem().(modelRef); ok {
|
||
m.currentProvider = item.provider
|
||
m.currentModel = item.model
|
||
}
|
||
m.screen = screenChat
|
||
return m, nil
|
||
}
|
||
ml, cmd := m.modelList.Update(msg)
|
||
m.modelList = ml
|
||
return m, cmd
|
||
}
|
||
|
||
func (m *chatbot) handleSessionPickerKey(key string, msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||
if key == "esc" {
|
||
m.screen = screenChat
|
||
return m, nil
|
||
}
|
||
if key == "enter" {
|
||
if item, ok := m.sessList.SelectedItem().(sessionItem); ok {
|
||
if idx := m.findSessionIndex(item.session.ID); idx >= 0 && idx != m.sessionIdx {
|
||
m.switchToSession(idx)
|
||
}
|
||
}
|
||
m.screen = screenChat
|
||
return m, nil
|
||
}
|
||
if key == "n" {
|
||
m.saveCurrentSession()
|
||
return m.newSessionInner()
|
||
}
|
||
if key == "d" {
|
||
if item, ok := m.sessList.SelectedItem().(sessionItem); ok {
|
||
return m.deleteSession(item.session.ID)
|
||
}
|
||
return m, nil
|
||
}
|
||
sl, cmd := m.sessList.Update(msg)
|
||
m.sessList = sl
|
||
return m, cmd
|
||
}
|
||
|
||
func (m *chatbot) findSessionIndex(id string) int {
|
||
for i, s := range m.sessions {
|
||
if s.ID == id {
|
||
return i
|
||
}
|
||
}
|
||
return -1
|
||
}
|
||
|
||
func (m *chatbot) newSession() (tea.Model, tea.Cmd) {
|
||
m.saveCurrentSession()
|
||
return m.newSessionInner()
|
||
}
|
||
|
||
func (m *chatbot) newSessionInner() (tea.Model, tea.Cmd) {
|
||
s := NewSessionData(m.currentProvider, m.currentModel)
|
||
m.sessions = append(m.sessions, s)
|
||
m.sessionIdx = len(m.sessions) - 1
|
||
m.messages = nil
|
||
m.history = nil
|
||
m.historyIdx = -1
|
||
m.updateViewport()
|
||
m.screen = screenChat
|
||
return m, nil
|
||
}
|
||
|
||
func (m *chatbot) closeCurrentSession() (tea.Model, tea.Cmd) {
|
||
if len(m.sessions) <= 1 {
|
||
return m, nil
|
||
}
|
||
_ = DeleteSession(m.sessions[m.sessionIdx].ID)
|
||
m.sessions = append(m.sessions[:m.sessionIdx], m.sessions[m.sessionIdx+1:]...)
|
||
if m.sessionIdx >= len(m.sessions) {
|
||
m.sessionIdx = len(m.sessions) - 1
|
||
}
|
||
m.switchToSession(m.sessionIdx)
|
||
return m, nil
|
||
}
|
||
|
||
func (m *chatbot) deleteSession(id string) (tea.Model, tea.Cmd) {
|
||
idx := m.findSessionIndex(id)
|
||
if idx < 0 || len(m.sessions) <= 1 {
|
||
return m, nil
|
||
}
|
||
_ = DeleteSession(m.sessions[idx].ID)
|
||
m.sessions = append(m.sessions[:idx], m.sessions[idx+1:]...)
|
||
if m.sessionIdx == idx {
|
||
if m.sessionIdx >= len(m.sessions) {
|
||
m.sessionIdx = len(m.sessions) - 1
|
||
}
|
||
m.switchToSession(m.sessionIdx)
|
||
} else if m.sessionIdx > idx {
|
||
m.sessionIdx--
|
||
}
|
||
m.sessList.SetItems(buildSessionItems(m.sessions, m.sessionIdx))
|
||
return m, nil
|
||
}
|
||
|
||
func (m *chatbot) switchToSession(idx int) {
|
||
m.sessionIdx = idx
|
||
s := m.sessions[idx]
|
||
m.messages = nil
|
||
if s.Messages != nil {
|
||
m.messages = s.Messages
|
||
}
|
||
m.history = nil
|
||
if s.History != nil {
|
||
m.history = s.History
|
||
}
|
||
m.historyIdx = -1
|
||
if s.Provider != "" {
|
||
m.currentProvider = s.Provider
|
||
}
|
||
if s.Model != "" {
|
||
m.currentModel = s.Model
|
||
}
|
||
m.updateViewport()
|
||
}
|
||
|
||
func (m *chatbot) handleSelKey(key string, msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||
totalLines := m.viewport.TotalLineCount()
|
||
|
||
switch key {
|
||
case "j", "down":
|
||
if m.selCurY < totalLines-1 {
|
||
m.selCurY++
|
||
}
|
||
m.follow = false
|
||
m.ensureSelCursorVisible()
|
||
return m, nil
|
||
|
||
case "k", "up":
|
||
if m.selCurY > 0 {
|
||
m.selCurY--
|
||
}
|
||
m.follow = false
|
||
m.ensureSelCursorVisible()
|
||
return m, nil
|
||
|
||
case "h", "left", "l", "right":
|
||
return m, nil
|
||
|
||
case "v":
|
||
if m.selVisual {
|
||
m.selVisual = false
|
||
m.selMarkY = -1
|
||
} else {
|
||
m.selVisual = true
|
||
m.selMarkY = m.selCurY
|
||
}
|
||
return m, nil
|
||
|
||
case "enter":
|
||
if m.selVisual {
|
||
m.extractVisualSelection()
|
||
}
|
||
m.selMode = false
|
||
m.selVisual = false
|
||
m.selMarkY = -1
|
||
m.textarea.Focus()
|
||
return m, nil
|
||
|
||
case "esc":
|
||
m.selMode = false
|
||
m.selVisual = false
|
||
m.selMarkY = -1
|
||
m.textarea.Focus()
|
||
return m, nil
|
||
|
||
default:
|
||
return m, nil
|
||
}
|
||
}
|
||
|
||
func (m *chatbot) ensureSelCursorVisible() {
|
||
if m.selCurY < m.viewport.YOffset() {
|
||
m.viewport.SetYOffset(m.selCurY)
|
||
} else if m.selCurY >= m.viewport.YOffset()+m.viewport.Height() {
|
||
m.viewport.SetYOffset(m.selCurY - m.viewport.Height() + 1)
|
||
}
|
||
}
|
||
|
||
func (m *chatbot) extractVisualSelection() {
|
||
start, end := m.selMarkY, m.selCurY
|
||
if start > end {
|
||
start, end = end, start
|
||
}
|
||
|
||
content := m.buildViewportContent()
|
||
contentLines := strings.Split(content, "\n")
|
||
vpWidth := m.width - 1
|
||
|
||
visualCount := 0
|
||
var selectedLines []string
|
||
for _, line := range contentLines {
|
||
w := lipgloss.Width(line)
|
||
numVisual := 1
|
||
if w > vpWidth {
|
||
numVisual = (w + vpWidth - 1) / vpWidth
|
||
}
|
||
visualStart := visualCount
|
||
visualEnd := visualCount + numVisual - 1
|
||
if visualStart <= end && visualEnd >= start {
|
||
selectedLines = append(selectedLines, line)
|
||
}
|
||
visualCount += numVisual
|
||
if visualCount > end {
|
||
break
|
||
}
|
||
}
|
||
|
||
result := strings.Join(selectedLines, "\n")
|
||
result = ansi.Strip(result)
|
||
result = strings.TrimSpace(result)
|
||
if result != "" {
|
||
clipboard.WriteAll(result)
|
||
}
|
||
}
|
||
|
||
func (m *chatbot) isLanding() bool {
|
||
return len(m.messages) == 0 && !m.streaming && m.screen == screenChat
|
||
}
|
||
|
||
func (m *chatbot) View() tea.View {
|
||
var v tea.View
|
||
v.AltScreen = true
|
||
v.BackgroundColor = m.styles.Bg
|
||
v.MouseMode = tea.MouseModeCellMotion
|
||
v.KeyboardEnhancements.ReportEventTypes = true
|
||
|
||
if !m.ready {
|
||
v.Content = "Initializing..."
|
||
return v
|
||
}
|
||
|
||
if m.screen == screenModelPicker {
|
||
v.Content = m.renderModelPicker()
|
||
return v
|
||
}
|
||
if m.screen == screenSessionPicker {
|
||
v.Content = m.renderSessionPicker()
|
||
return v
|
||
}
|
||
|
||
sessionTag := fmt.Sprintf("[%d/%d] %s", m.sessionIdx+1, len(m.sessions), m.currentModel)
|
||
|
||
var modeStr string
|
||
if m.selVisual {
|
||
modeStr = "--VISUAL--"
|
||
} else if m.selMode {
|
||
modeStr = "--SELECT--"
|
||
}
|
||
header := renderHeader(m.styles, sessionTag, m.width, modeStr)
|
||
|
||
var body string
|
||
if m.isLanding() {
|
||
w := min(m.width*2/5, 200)
|
||
m.textarea.SetWidth(w - 6)
|
||
body = m.renderLanding()
|
||
} else {
|
||
m.textarea.SetWidth(m.width - 8)
|
||
m.updateViewport()
|
||
vpContent := m.viewport.View()
|
||
scrollbar := renderScrollbar(m.viewport.Height(), m.viewport.TotalLineCount(), m.viewport.Height(), m.viewport.YOffset(), m.styles.ScrollbarThumb, m.styles.ScrollbarTrack)
|
||
if scrollbar != "" {
|
||
body = lipgloss.JoinHorizontal(lipgloss.Top, vpContent, scrollbar)
|
||
} else {
|
||
body = vpContent
|
||
}
|
||
}
|
||
|
||
inputContent := m.textarea.View()
|
||
var inputBox string
|
||
if m.isLanding() {
|
||
w := min(m.width*2/5, 200)
|
||
inputBox = m.styles.InputBorder.Width(w).Render(inputContent)
|
||
v.Content = header + "\n" + body + "\n" + lipgloss.NewStyle().Width(m.width).Align(lipgloss.Center).Render(inputBox)
|
||
} else {
|
||
inputBox = m.styles.InputBorder.Width(m.width).Render(inputContent)
|
||
v.Content = header + "\n" + body + "\n" + inputBox
|
||
}
|
||
return v
|
||
}
|
||
|
||
func (m *chatbot) renderLanding() string {
|
||
name := applyBoldGrad(m.styles.Logo, "LUDMILA", m.styles.LogoGradFrom, m.styles.LogoGradTo)
|
||
diag := m.styles.LogoDiagonals.Render("╱╱╱╱╱")
|
||
logoLine := name + " " + diag
|
||
|
||
hint := m.styles.Help.Render("how can I help?")
|
||
|
||
content := lipgloss.JoinVertical(lipgloss.Center,
|
||
logoLine,
|
||
"",
|
||
hint,
|
||
)
|
||
|
||
avail := m.height - 8
|
||
contentH := strings.Count(content, "\n") + 1
|
||
topPad := (avail - contentH) / 2
|
||
if topPad < 0 {
|
||
topPad = 0
|
||
}
|
||
|
||
return strings.Repeat("\n", topPad) + lipgloss.NewStyle().Width(m.width).Align(lipgloss.Center).Render(content)
|
||
}
|
||
|
||
func (m *chatbot) renderModelPicker() string {
|
||
info := m.currentModel
|
||
header := renderHeader(m.styles, info, m.width, "")
|
||
|
||
lw := min(m.width*3/5, 400)
|
||
if lw < 20 {
|
||
lw = 20
|
||
}
|
||
|
||
titleLine := dialogTitle("Select model", m.styles.Logo, lw, m.styles.LogoGradTo, m.styles.LogoGradFrom)
|
||
m.modelList.SetWidth(lw)
|
||
listView := m.modelList.View()
|
||
body := titleLine + "\n" + listView
|
||
|
||
box := m.styles.DialogView.
|
||
Width(lw + 2).
|
||
Padding(0, 1).
|
||
Render(body)
|
||
|
||
return header + "\n" + lipgloss.NewStyle().Width(m.width).Align(lipgloss.Center).Render(box)
|
||
}
|
||
|
||
func (m *chatbot) renderSessionPicker() string {
|
||
label := fmt.Sprintf("[%d/%d]", m.sessionIdx+1, len(m.sessions))
|
||
header := renderHeader(m.styles, label, m.width, "")
|
||
|
||
lw := min(m.width*3/5, 400)
|
||
if lw < 20 {
|
||
lw = 20
|
||
}
|
||
|
||
titleLine := dialogTitle("Sessions", m.styles.Logo, lw, m.styles.LogoGradTo, m.styles.LogoGradFrom)
|
||
m.sessList.SetWidth(lw)
|
||
listView := m.sessList.View()
|
||
body := titleLine + "\n" + listView
|
||
|
||
box := m.styles.DialogView.
|
||
Width(lw + 2).
|
||
Padding(0, 1).
|
||
Render(body)
|
||
|
||
return header + "\n" + lipgloss.NewStyle().Width(m.width).Align(lipgloss.Center).Render(box)
|
||
}
|
||
|
||
func (m *chatbot) buildViewportContent() string {
|
||
var b strings.Builder
|
||
w := m.width - 2
|
||
innerW := w - 2
|
||
lineIdx := 0
|
||
|
||
for _, msg := range m.messages {
|
||
var rendered string
|
||
switch msg.Role {
|
||
case "user":
|
||
rendered = renderMsgWithCode(msg.Content, innerW, m.styles.UserFocused, m.styles.CodeBlock, m.styles.InlineBold, m.styles.InlineItalic, m.styles.InlineCode, m.styles.Heading, m.styles.H1)
|
||
case "assistant":
|
||
rendered = renderMsgWithCode(msg.Content, innerW, m.styles.AsstFocused, m.styles.CodeBlock, m.styles.InlineBold, m.styles.InlineItalic, m.styles.InlineCode, m.styles.Heading, m.styles.H1)
|
||
case "system":
|
||
rendered = m.styles.Error.Render(" " + msg.Content)
|
||
default:
|
||
rendered = renderMsgWithCode(msg.Content, innerW, m.styles.AsstFocused, m.styles.CodeBlock, m.styles.InlineBold, m.styles.InlineItalic, m.styles.InlineCode, m.styles.Heading, m.styles.H1)
|
||
}
|
||
msgLines := strings.Split(rendered, "\n")
|
||
for _, line := range msgLines {
|
||
line = m.applySelHighlight(lineIdx, line)
|
||
b.WriteString(line)
|
||
b.WriteByte('\n')
|
||
lineIdx++
|
||
}
|
||
b.WriteString("\n")
|
||
lineIdx++
|
||
}
|
||
if m.streaming {
|
||
partial := m.respBuf.String()
|
||
if partial != "" {
|
||
rendered := renderMsgWithCode(partial, innerW, m.styles.AsstFocused, m.styles.CodeBlock, m.styles.InlineBold, m.styles.InlineItalic, m.styles.InlineCode, m.styles.Heading, m.styles.H1)
|
||
pLines := strings.Split(rendered, "\n")
|
||
for _, line := range pLines {
|
||
line = m.applySelHighlight(lineIdx, line)
|
||
b.WriteString(line)
|
||
b.WriteByte('\n')
|
||
lineIdx++
|
||
}
|
||
} else {
|
||
b.WriteString("\n")
|
||
}
|
||
}
|
||
res := strings.TrimRight(b.String(), "\n")
|
||
if res != "" {
|
||
res += "\n"
|
||
}
|
||
return res
|
||
}
|
||
|
||
func (m *chatbot) applySelHighlight(lineIdx int, line string) string {
|
||
if !m.selMode {
|
||
return line
|
||
}
|
||
var bg string
|
||
if m.selVisual {
|
||
start, end := m.selMarkY, m.selCurY
|
||
if start > end {
|
||
start, end = end, start
|
||
}
|
||
if lineIdx >= start && lineIdx <= end {
|
||
bg = m.styles.SelVisualBg
|
||
}
|
||
}
|
||
if bg == "" && lineIdx == m.selCurY {
|
||
bg = m.styles.SelCursorBg
|
||
}
|
||
if bg == "" {
|
||
return line
|
||
}
|
||
close := "\033[0m"
|
||
return bg + strings.ReplaceAll(line, close, close+bg)
|
||
}
|
||
|
||
func renderInline(text string, boldStyle, italicStyle, codeStyle, headingStyle, h1Style lipgloss.Style) string {
|
||
if strings.HasPrefix(text, "# ") {
|
||
rest := strings.TrimSpace(text[2:])
|
||
return h1Style.Render(" " + rest + " ")
|
||
}
|
||
if strings.HasPrefix(text, "## ") || strings.HasPrefix(text, "### ") ||
|
||
strings.HasPrefix(text, "#### ") || strings.HasPrefix(text, "##### ") ||
|
||
strings.HasPrefix(text, "###### ") {
|
||
idx := strings.IndexByte(text, ' ')
|
||
rest := strings.TrimSpace(text[idx+1:])
|
||
return headingStyle.Render(rest)
|
||
}
|
||
|
||
if strings.HasPrefix(text, "* ") {
|
||
text = "• " + text[2:]
|
||
}
|
||
if strings.HasPrefix(text, "- ") {
|
||
text = "• " + text[2:]
|
||
}
|
||
|
||
runes := []rune(text)
|
||
var b strings.Builder
|
||
i := 0
|
||
for i < len(runes) {
|
||
if i+1 < len(runes) && runes[i] == '*' && runes[i+1] == '*' {
|
||
j := i + 2
|
||
closed := false
|
||
for j < len(runes)-1 {
|
||
if runes[j] == '*' && runes[j+1] == '*' {
|
||
b.WriteString(boldStyle.Render(string(runes[i+2 : j])))
|
||
i = j + 2
|
||
closed = true
|
||
break
|
||
}
|
||
j++
|
||
}
|
||
if closed {
|
||
continue
|
||
}
|
||
b.WriteRune(runes[i])
|
||
i++
|
||
} else if runes[i] == '*' {
|
||
j := i + 1
|
||
closed := false
|
||
for j < len(runes) {
|
||
if runes[j] == '*' {
|
||
b.WriteString(italicStyle.Render(string(runes[i+1 : j])))
|
||
i = j + 1
|
||
closed = true
|
||
break
|
||
}
|
||
j++
|
||
}
|
||
if closed {
|
||
continue
|
||
}
|
||
b.WriteRune(runes[i])
|
||
i++
|
||
} else if runes[i] == '`' {
|
||
j := i + 1
|
||
closed := false
|
||
for j < len(runes) {
|
||
if runes[j] == '`' {
|
||
b.WriteString(codeStyle.Render(string(runes[i+1 : j])))
|
||
i = j + 1
|
||
closed = true
|
||
break
|
||
}
|
||
j++
|
||
}
|
||
if closed {
|
||
continue
|
||
}
|
||
b.WriteRune(runes[i])
|
||
i++
|
||
} else {
|
||
b.WriteRune(runes[i])
|
||
i++
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func renderMsgWithCode(content string, innerWidth int, msgBorder, codeBlock, inlineBold, inlineItalic, inlineCode, headingStyle, h1Style lipgloss.Style) string {
|
||
const fence = "```"
|
||
segments := strings.Split(content, fence)
|
||
if len(segments) <= 1 {
|
||
wrapped := wrapText(content, innerWidth)
|
||
lines := strings.Split(wrapped, "\n")
|
||
for li, line := range lines {
|
||
lines[li] = renderInline(line, inlineBold, inlineItalic, inlineCode, headingStyle, h1Style)
|
||
}
|
||
return msgBorder.Render(strings.Join(lines, "\n"))
|
||
}
|
||
|
||
var b strings.Builder
|
||
for i, seg := range segments {
|
||
if seg == "" {
|
||
continue
|
||
}
|
||
if i%2 == 1 {
|
||
if idx := strings.IndexByte(seg, '\n'); idx >= 0 {
|
||
seg = seg[idx+1:]
|
||
}
|
||
if seg != "" {
|
||
b.WriteString(codeBlock.Render(seg))
|
||
}
|
||
} else {
|
||
wrapped := wrapText(seg, innerWidth)
|
||
lines := strings.Split(wrapped, "\n")
|
||
for li, line := range lines {
|
||
lines[li] = renderInline(line, inlineBold, inlineItalic, inlineCode, headingStyle, h1Style)
|
||
}
|
||
b.WriteString(strings.Join(lines, "\n"))
|
||
}
|
||
}
|
||
return msgBorder.Render(b.String())
|
||
}
|
||
|
||
func (m *chatbot) updateViewport() {
|
||
m.viewport.SetContent(m.buildViewportContent())
|
||
if m.follow {
|
||
m.viewport.GotoBottom()
|
||
}
|
||
}
|
||
|
||
func (m *chatbot) recalcLayout() {
|
||
taH := m.textarea.Height()
|
||
if taH < 1 {
|
||
taH = 1
|
||
}
|
||
m.textareaHeight = taH
|
||
overhead := 7
|
||
vpH := m.height - overhead - taH
|
||
if vpH < 1 {
|
||
vpH = 1
|
||
}
|
||
m.viewport.SetHeight(vpH)
|
||
m.updateViewport()
|
||
}
|