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