ludmila-ai-tui/chatbot.go
2026-06-03 15:05:38 +07:00

1114 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()
}