commit 67c8984b9518e632ac8cb4baaf01ea53ca33d2ae Author: Maksim Totmin Date: Wed Jun 3 15:05:38 2026 +0700 first commit diff --git a/_ref_crush b/_ref_crush new file mode 160000 index 0000000..aeda508 --- /dev/null +++ b/_ref_crush @@ -0,0 +1 @@ +Subproject commit aeda508da29bc2f6e22e84c97007c87d83496466 diff --git a/chatbot.go b/chatbot.go new file mode 100644 index 0000000..9d8156d --- /dev/null +++ b/chatbot.go @@ -0,0 +1,1113 @@ +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() +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..a4364c3 --- /dev/null +++ b/config.go @@ -0,0 +1,116 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +type ProviderConfig struct { + Name string `json:"name,omitempty"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key,omitempty"` + Type string `json:"type,omitempty"` +} + +type Config struct { + Theme string `json:"theme,omitempty"` + DefaultProvider string `json:"default_provider,omitempty"` + Providers map[string]ProviderConfig `json:"providers,omitempty"` +} + +func configDir() string { + dir := os.Getenv("XDG_CONFIG_HOME") + if dir == "" { + home, _ := os.UserHomeDir() + dir = filepath.Join(home, ".config") + } + return filepath.Join(dir, "ludmila-ai-tui") +} + +func configPath() string { + return filepath.Join(configDir(), "config.json") +} + +func themesDir() string { + return filepath.Join(configDir(), "themes") +} + +var defaultProviders = map[string]ProviderConfig{ + "Mathink": { + Name: "Mathink (FLM)", + BaseURL: "http://localhost:52625", + Type: "openai-compat", + }, +} + +func ensureConfigDir() error { + dir := configDir() + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create config dir %s: %w", dir, err) + } + if err := os.MkdirAll(themesDir(), 0o755); err != nil { + return fmt.Errorf("create themes dir: %w", err) + } + return nil +} + +func LoadConfig() (*Config, error) { + cfg := &Config{ + Theme: "system", + Providers: defaultProviders, + } + + path := configPath() + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + if mkErr := ensureConfigDir(); mkErr != nil { + return cfg, fmt.Errorf("ensure config dir: %w", mkErr) + } + defaultJSON, _ := json.MarshalIndent(cfg, "", " ") + _ = os.WriteFile(path, defaultJSON, 0o644) + return cfg, nil + } + return cfg, fmt.Errorf("read config %s: %w", path, err) + } + + if err := json.Unmarshal(data, cfg); err != nil { + return cfg, fmt.Errorf("parse config %s: %w", path, err) + } + + if cfg.Theme == "" { + cfg.Theme = "system" + } + + if cfg.Providers == nil || len(cfg.Providers) == 0 { + cfg.Providers = defaultProviders + } + + for id, p := range cfg.Providers { + p.APIKey = os.ExpandEnv(p.APIKey) + if p.Type == "" { + p.Type = "openai-compat" + } + cfg.Providers[id] = p + } + + var found bool + if cfg.DefaultProvider != "" { + for id := range cfg.Providers { + if id == cfg.DefaultProvider { + found = true + break + } + } + } + if !found { + for id := range cfg.Providers { + cfg.DefaultProvider = id + break + } + } + + return cfg, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..57b15e7 --- /dev/null +++ b/go.mod @@ -0,0 +1,29 @@ +module tui-ai-chats + +go 1.26.3 + +require ( + charm.land/bubbles/v2 v2.1.0 + charm.land/bubbletea/v2 v2.0.7 + charm.land/lipgloss/v2 v2.0.3 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9a6ef73 --- /dev/null +++ b/go.sum @@ -0,0 +1,50 @@ +charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= +charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= +charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0= +charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs= +charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= +charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 h1:FpSYhY28ucg9ZRr+2wj67FAQ0Ey5yiK0072PmRDJNek= +github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/llm.go b/llm.go new file mode 100644 index 0000000..0928ccf --- /dev/null +++ b/llm.go @@ -0,0 +1,159 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + tea "charm.land/bubbletea/v2" +) + +type chatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type chatRequest struct { + Model string `json:"model"` + Messages []chatMessage `json:"messages"` + Stream bool `json:"stream"` +} + +type sseChunk struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []struct { + Index int `json:"index"` + Delta chatMessage `json:"delta"` + FinishReason *string `json:"finish_reason"` + } `json:"choices"` +} + +type tagModel struct { + Name string `json:"name"` +} + +type listTagsResp struct { + Models []tagModel `json:"models"` +} + +type streamChunkMsg string +type streamDoneMsg struct{} +type streamErrMsg struct{ err error } + +func fetchModels(baseURL string) ([]string, error) { + resp, err := http.Get(strings.TrimRight(baseURL, "/") + "/api/tags") + if err != nil { + return nil, fmt.Errorf("failed to fetch models: %w", err) + } + defer resp.Body.Close() + + var r listTagsResp + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return nil, fmt.Errorf("failed to decode models: %w", err) + } + + models := make([]string, len(r.Models)) + for i, m := range r.Models { + models[i] = m.Name + } + return models, nil +} + +func startStream(ctx context.Context, msgs []chatMessage, model, baseURL string) (<-chan tea.Msg, tea.Cmd) { + ch := make(chan tea.Msg, 16) + go runStream(ctx, ch, msgs, model, baseURL) + return ch, readNext(ch) +} + +func readNext(ch <-chan tea.Msg) tea.Cmd { + return func() tea.Msg { + msg, ok := <-ch + if !ok { + return streamDoneMsg{} + } + return msg + } +} + +func runStream(ctx context.Context, ch chan<- tea.Msg, msgs []chatMessage, model, baseURL string) { + defer close(ch) + + reqBody := chatRequest{ + Model: model, + Messages: msgs, + Stream: true, + } + + body, err := json.Marshal(reqBody) + if err != nil { + ch <- streamErrMsg{err: fmt.Errorf("marshal request: %w", err)} + return + } + + url := strings.TrimRight(baseURL, "/") + "/v1/chat/completions" + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) + if err != nil { + ch <- streamErrMsg{err: fmt.Errorf("create request: %w", err)} + return + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "text/event-stream") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + if ctx.Err() != nil { + return + } + ch <- streamErrMsg{err: fmt.Errorf("http request: %w", err)} + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + errBody, _ := io.ReadAll(resp.Body) + ch <- streamErrMsg{err: fmt.Errorf("api %d: %s", resp.StatusCode, string(errBody))} + return + } + + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + if ctx.Err() != nil { + return + } + + line := strings.TrimSpace(scanner.Text()) + + if line == "" || !strings.HasPrefix(line, "data:") { + continue + } + + data := strings.TrimSpace(line[5:]) + if data == "[DONE]" { + break + } + + var chunk sseChunk + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + continue + } + + if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" { + ch <- streamChunkMsg(chunk.Choices[0].Delta.Content) + } + } + + if err := scanner.Err(); err != nil { + if ctx.Err() != nil { + return + } + ch <- streamErrMsg{err: fmt.Errorf("scanner: %w", err)} + } +} diff --git a/ludmila-ai-tui b/ludmila-ai-tui new file mode 100755 index 0000000..0fe252d Binary files /dev/null and b/ludmila-ai-tui differ diff --git a/main.go b/main.go new file mode 100644 index 0000000..07adedd --- /dev/null +++ b/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "fmt" + "os" + + tea "charm.land/bubbletea/v2" +) + +func main() { + cfg, err := LoadConfig() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err) + os.Exit(1) + } + + theme := LoadTheme(cfg.Theme, themesDir()) + sty := BuildStyles(theme) + + sessions, err := ListSessions() + if err != nil { + sessions = nil + } + + cb, err := newChatbot(cfg.Providers, sessions, sty) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to init: %v\n", err) + os.Exit(1) + } + + p := tea.NewProgram(cb) + if _, err := p.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/session.go b/session.go new file mode 100644 index 0000000..5a33ccf --- /dev/null +++ b/session.go @@ -0,0 +1,216 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "charm.land/bubbles/v2/list" +) + +type SessionData struct { + ID string `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Title string `json:"title"` + Provider string `json:"provider"` + Model string `json:"model"` + Messages []message `json:"messages"` + History []string `json:"history"` +} + +type sessionFile struct { + Session SessionData `json:"session"` + Deleted bool `json:"-"` +} + +func currentSession(m *chatbot) *SessionData { + if m.sessionIdx < 0 || m.sessionIdx >= len(m.sessions) { + return nil + } + return &m.sessions[m.sessionIdx] +} + +func dataDir() string { + dir := os.Getenv("XDG_DATA_HOME") + if dir == "" { + home, _ := os.UserHomeDir() + dir = filepath.Join(home, ".local", "share") + } + return filepath.Join(dir, "ludmila-ai-tui") +} + +func sessionsDir() string { + return filepath.Join(dataDir(), "sessions") +} + +func sessionPath(id string) string { + return filepath.Join(sessionsDir(), id+".json") +} + +func ListSessions() ([]SessionData, error) { + dir := sessionsDir() + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read sessions dir: %w", err) + } + + var sessions []SessionData + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + data, err := os.ReadFile(filepath.Join(dir, entry.Name())) + if err != nil { + continue + } + var file sessionFile + if err := json.Unmarshal(data, &file); err != nil { + continue + } + if file.Deleted { + continue + } + sessions = append(sessions, file.Session) + } + + sort.Slice(sessions, func(i, j int) bool { + return sessions[i].UpdatedAt.After(sessions[j].UpdatedAt) + }) + + return sessions, nil +} + +func SaveSession(s SessionData) error { + dir := sessionsDir() + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create sessions dir: %w", err) + } + + s.UpdatedAt = time.Now() + file := sessionFile{Session: s} + + data, err := json.MarshalIndent(file, "", " ") + if err != nil { + return fmt.Errorf("marshal session: %w", err) + } + + if err := os.WriteFile(sessionPath(s.ID), data, 0o644); err != nil { + return fmt.Errorf("write session: %w", err) + } + + return nil +} + +func DeleteSession(id string) error { + data, err := os.ReadFile(sessionPath(id)) + if err != nil { + return fmt.Errorf("read session: %w", err) + } + + var file sessionFile + if err := json.Unmarshal(data, &file); err != nil { + return fmt.Errorf("unmarshal session: %w", err) + } + + file.Deleted = true + updatedData, err := json.MarshalIndent(file, "", " ") + if err != nil { + return fmt.Errorf("marshal deleted session: %w", err) + } + + return os.WriteFile(sessionPath(id), updatedData, 0o644) +} + +func NewSessionData(provider, model string) SessionData { + now := time.Now() + + return SessionData{ + ID: now.Format("20060102_150405"), + CreatedAt: now, + UpdatedAt: now, + Title: "New session", + Provider: provider, + Model: model, + } +} + +func sessionTitle(s SessionData) string { + if s.Title != "" && s.Title != "New session" { + return s.Title + } + return "New session" +} + +func (m *chatbot) saveCurrentSession() { + if cs := currentSession(m); cs != nil { + cs.UpdatedAt = time.Now() + cs.Messages = m.messages + cs.History = m.history + cs.Provider = m.currentProvider + cs.Model = m.currentModel + + if len(cs.Messages) > 0 && (cs.Title == "" || cs.Title == "New session") { + cs.Title = sessionTitleFromMessages(cs.Messages) + } + + m.sessions[m.sessionIdx] = *cs + _ = SaveSession(*cs) + } +} + +func sessionTitleFromMessages(msgs []message) string { + if len(msgs) == 0 { + return "New session" + } + title := strings.TrimSpace(msgs[0].Content) + if idx := strings.IndexAny(title, ".\n"); idx > 0 { + title = title[:idx] + } + runes := []rune(title) + if len(runes) > 40 { + title = string(runes[:37]) + "..." + } + return title +} + +type sessionItem struct { + session SessionData + active bool +} + +func (s sessionItem) Title() string { + prefix := " " + if s.active { + prefix = "* " + } + return prefix + sessionTitle(s.session) +} + +func (s sessionItem) Description() string { + parts := []string{ + s.session.Provider + "/" + s.session.Model, + "(" + fmt.Sprintf("%d msgs", len(s.session.Messages)) + ")", + s.session.UpdatedAt.Format("Jan 02 15:04"), + } + return strings.Join(parts, " · ") +} + +func (s sessionItem) FilterValue() string { + return s.session.Title + " " + s.session.Provider + " " + s.session.Model +} + +func buildSessionItems(sessions []SessionData, activeIdx int) []list.Item { + items := make([]list.Item, len(sessions)) + for i, s := range sessions { + items[i] = sessionItem{session: s, active: i == activeIdx} + } + return items +} diff --git a/theme.go b/theme.go new file mode 100644 index 0000000..41699e4 --- /dev/null +++ b/theme.go @@ -0,0 +1,468 @@ +package main + +import ( + "encoding/json" + "fmt" + "image/color" + "os" + "path/filepath" + "strconv" + "strings" + + lipgloss "charm.land/lipgloss/v2" +) + +type Token int + +const ( + TokenBg Token = iota + TokenFg + TokenFgMuted + TokenFgMostSubtle + TokenPrimary + TokenSecondary + TokenAccent + TokenSuccess + TokenSuccessSubtle + TokenUser + TokenAssistant + TokenBorder + TokenCodeBlock + TokenHeaderBg + TokenHeaderFg + TokenStreaming + TokenBgSubtle + TokenOnPrimary +) + +func tokenKey(t Token) string { + switch t { + case TokenBg: + return "bg" + case TokenFg: + return "fg" + case TokenFgMuted: + return "fg_muted" + case TokenFgMostSubtle: + return "fg_most_subtle" + case TokenPrimary: + return "primary" + case TokenSecondary: + return "secondary" + case TokenAccent: + return "accent" + case TokenSuccess: + return "success" + case TokenSuccessSubtle: + return "success_subtle" + case TokenUser: + return "user" + case TokenAssistant: + return "assistant" + case TokenBorder: + return "border" + case TokenCodeBlock: + return "code_block" + case TokenHeaderBg: + return "header_bg" + case TokenHeaderFg: + return "header_fg" + case TokenStreaming: + return "streaming" + case TokenBgSubtle: + return "bg_subtle" + case TokenOnPrimary: + return "on_primary" + } + return "" +} + +type Theme map[Token]string + +func SystemTheme() Theme { + return Theme{ + TokenBg: "#303030", + TokenFg: "#d7dae0", + TokenFgMuted: "#abb2bf", + TokenFgMostSubtle: "#565c64", + TokenPrimary: "#56b6c2", + TokenSecondary: "#e06c75", + TokenAccent: "#c678dd", + TokenSuccess: "#98c379", + TokenSuccessSubtle: "#98c379", + TokenUser: "#56b6c2", + TokenAssistant: "#98c379", + TokenBorder: "#353b45", + TokenCodeBlock: "#353b45", + TokenHeaderBg: "#232326", + TokenHeaderFg: "#d7dae0", + TokenStreaming: "#e06c75", + TokenBgSubtle: "#232326", + TokenOnPrimary: "#c8ccd4", + } +} + +func LoadTheme(name, themesDirectory string) Theme { + if name == "" || name == "system" { + return SystemTheme() + } + + path := filepath.Join(themesDirectory, name+".json") + data, err := os.ReadFile(path) + if err != nil { + return SystemTheme() + } + + type rawTheme struct { + Colors map[string]string `json:"colors"` + } + var full rawTheme + if err := json.Unmarshal(data, &full); err != nil { + return SystemTheme() + } + + t := SystemTheme() + for k, v := range full.Colors { + for tok := TokenBg; tok <= TokenOnPrimary; tok++ { + if tokenKey(tok) == k { + t[tok] = v + break + } + } + } + + return t +} + +func themeColor(val string) color.Color { + if val == "" { + return lipgloss.NoColor{} + } + if n, err := strconv.Atoi(val); err == nil && n >= 0 && n <= 255 { + return lipgloss.Color(strconv.Itoa(n)) + } + if strings.HasPrefix(val, "#") && len(val) == 7 { + return lipgloss.Color(val) + } + return lipgloss.NoColor{} +} + +var thickLeftBorder = lipgloss.Border{Left: "▌"} + +type Styles struct { + Bg color.Color + BgSubtle color.Color + Fg color.Color + + Logo lipgloss.Style + LogoDiagonals lipgloss.Style + LogoCharm lipgloss.Style + LogoVersion lipgloss.Style + LogoGradFrom color.Color + LogoGradTo color.Color + HeaderDiagonals lipgloss.Style + HeaderSep lipgloss.Style + HeaderWrap lipgloss.Style + UserFocused lipgloss.Style + UserBlurred lipgloss.Style + AsstFocused lipgloss.Style + AsstBlurred lipgloss.Style + ModelInfoIcon lipgloss.Style + ModelInfoName lipgloss.Style + ModelInfoProvider lipgloss.Style + Help lipgloss.Style + Error lipgloss.Style + Streaming lipgloss.Style + CodeBlock lipgloss.Style + InlineBold lipgloss.Style + InlineItalic lipgloss.Style + InlineCode lipgloss.Style + Heading lipgloss.Style + H1 lipgloss.Style + DialogTitle lipgloss.Style + DialogTitleAccent lipgloss.Style + DialogView lipgloss.Style + DialogSelected lipgloss.Style + DialogNormal lipgloss.Style + DialogContentBg lipgloss.Style + InputBorder lipgloss.Style + ScrollbarThumb lipgloss.Style + ScrollbarTrack lipgloss.Style + ModelPickerBorder color.Color + PromptFocused lipgloss.Style + PromptBlurred lipgloss.Style + BtnFocused lipgloss.Style + BtnBlurred lipgloss.Style + SelCursor lipgloss.Style + SelVisual lipgloss.Style + SelCursorBg string + SelVisualBg string +} + +func BuildStyles(t Theme) Styles { + return Styles{ + Bg: themeColor(t[TokenBg]), + BgSubtle: themeColor(t[TokenBgSubtle]), + Fg: themeColor(t[TokenFg]), + + Logo: lipgloss.NewStyle(), + LogoDiagonals: lipgloss.NewStyle().Foreground(themeColor(t[TokenPrimary])), + LogoCharm: lipgloss.NewStyle().Foreground(themeColor(t[TokenPrimary])).Bold(true), + LogoVersion: lipgloss.NewStyle().Foreground(themeColor(t[TokenPrimary])), + LogoGradFrom: themeColor(t[TokenSecondary]), + LogoGradTo: themeColor(t[TokenPrimary]), + + HeaderDiagonals: lipgloss.NewStyle().Foreground(themeColor(t[TokenPrimary])), + HeaderSep: lipgloss.NewStyle().Foreground(themeColor(t[TokenFgMuted])), + HeaderWrap: lipgloss.NewStyle(). + Background(themeColor(t[TokenHeaderBg])). + Foreground(themeColor(t[TokenHeaderFg])). + Padding(0, 1), + + UserFocused: lipgloss.NewStyle(). + Border(thickLeftBorder, false, false, false, true). + BorderForeground(themeColor(t[TokenUser])). + Foreground(themeColor(t[TokenFgMuted])). + Padding(0, 0, 0, 1), + UserBlurred: lipgloss.NewStyle(). + BorderLeft(true). + BorderForeground(themeColor(t[TokenUser])). + Foreground(themeColor(t[TokenFgMuted])). + Padding(0, 0, 0, 1), + + AsstFocused: lipgloss.NewStyle(). + Border(thickLeftBorder, false, false, false, true). + BorderForeground(themeColor(t[TokenAssistant])). + Padding(0, 0, 0, 1), + AsstBlurred: lipgloss.NewStyle(). + BorderLeft(true). + BorderForeground(themeColor(t[TokenAssistant])). + Padding(0, 0, 0, 1), + + ModelInfoIcon: lipgloss.NewStyle().Foreground(themeColor(t[TokenFgMostSubtle])), + ModelInfoName: lipgloss.NewStyle().Foreground(themeColor(t[TokenFg])), + ModelInfoProvider: lipgloss.NewStyle().Foreground(themeColor(t[TokenFgMuted])), + + Help: lipgloss.NewStyle().Foreground(themeColor(t[TokenFgMuted])), + Error: lipgloss.NewStyle().Foreground(themeColor(t[TokenSecondary])), + Streaming: lipgloss.NewStyle().Foreground(themeColor(t[TokenStreaming])), + + CodeBlock: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(themeColor(t[TokenCodeBlock])). + Background(themeColor(t[TokenBgSubtle])). + Padding(0, 1), + + InlineBold: lipgloss.NewStyle(). + Bold(true). + Foreground(themeColor(t[TokenFg])), + + InlineItalic: lipgloss.NewStyle(). + Foreground(themeColor(t[TokenSuccess])), + + InlineCode: lipgloss.NewStyle(). + Foreground(themeColor(t[TokenPrimary])). + Background(themeColor(t[TokenCodeBlock])), + + Heading: lipgloss.NewStyle(). + Bold(true). + Foreground(themeColor(t[TokenPrimary])), + + H1: lipgloss.NewStyle(). + Bold(true). + Background(themeColor(t[TokenPrimary])). + Foreground(themeColor(t[TokenOnPrimary])), + + DialogTitle: lipgloss.NewStyle().Padding(0, 1).Foreground(themeColor(t[TokenPrimary])), + DialogTitleAccent: lipgloss.NewStyle().Foreground(themeColor(t[TokenSuccess])).Bold(true), + DialogView: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(themeColor(t[TokenPrimary])), + DialogSelected: lipgloss.NewStyle().Padding(0, 1). + Background(themeColor(t[TokenPrimary])). + Foreground(themeColor(t[TokenOnPrimary])), + DialogNormal: lipgloss.NewStyle().Padding(0, 1). + Foreground(themeColor(t[TokenFg])), + DialogContentBg: lipgloss.NewStyle(). + Background(themeColor(t[TokenBgSubtle])). + Foreground(themeColor(t[TokenFg])). + Padding(1, 2), + + InputBorder: lipgloss.NewStyle(). + Background(themeColor(t[TokenBgSubtle])). + Padding(2, 3), + + ScrollbarThumb: lipgloss.NewStyle(). + Foreground(themeColor(t[TokenSecondary])), + + ScrollbarTrack: lipgloss.NewStyle(). + Foreground(themeColor(t[TokenFgMuted])), + + ModelPickerBorder: themeColor(t[TokenPrimary]), + + PromptFocused: lipgloss.NewStyle().Foreground(themeColor(t[TokenAccent])), + PromptBlurred: lipgloss.NewStyle().Foreground(themeColor(t[TokenFgMuted])), + + BtnFocused: lipgloss.NewStyle().Padding(0, 2). + Foreground(themeColor(t[TokenOnPrimary])). + Background(themeColor(t[TokenSecondary])), + BtnBlurred: lipgloss.NewStyle().Padding(0, 2). + Foreground(themeColor(t[TokenFg])). + Background(themeColor(t[TokenBgSubtle])), + + SelCursor: lipgloss.NewStyle(). + Background(lipgloss.Color("#2d3f41")), + + SelVisual: lipgloss.NewStyle(). + Background(lipgloss.Color("#3a2540")), + + SelCursorBg: hexToAnsiBg("#2d3f41"), + SelVisualBg: hexToAnsiBg("#3a2540"), + } +} + +func hexToAnsiBg(hex string) string { + if len(hex) < 7 || hex[0] != '#' { + return "" + } + r, _ := strconv.ParseUint(hex[1:3], 16, 8) + g, _ := strconv.ParseUint(hex[3:5], 16, 8) + b, _ := strconv.ParseUint(hex[5:7], 16, 8) + return fmt.Sprintf("\033[48;2;%d;%d;%dm", r, g, b) +} + +func foregroundGrad(base lipgloss.Style, input string, bold bool, c1, c2 color.Color) []string { + if input == "" { + return []string{""} + } + if len(input) == 1 { + return []string{base.Foreground(c1).Bold(bold).Render(input)} + } + runes := []rune(input) + ramp := lipgloss.Blend1D(len(runes), c1, c2) + result := make([]string, len(runes)) + for i, c := range ramp { + s := base.Foreground(c) + if bold { + s = s.Bold(true) + } + result[i] = s.Render(string(runes[i])) + } + return result +} + +func applyForegroundGrad(base lipgloss.Style, input string, c1, c2 color.Color) string { + clusters := foregroundGrad(base, input, false, c1, c2) + return strings.Join(clusters, "") +} + +func applyBoldGrad(base lipgloss.Style, input string, c1, c2 color.Color) string { + clusters := foregroundGrad(base, input, true, c1, c2) + return strings.Join(clusters, "") +} + +func renderCompactLogo(sty Styles) string { + name := applyBoldGrad(sty.Logo, "LUDMILA", sty.LogoGradFrom, sty.LogoGradTo) + diag := sty.LogoDiagonals.Render("╱╱╱╱╱") + return name + " " + diag +} + +func renderHeader(sty Styles, model string, width int, mode string) string { + logoPart := renderCompactLogo(sty) + + modelStyled := lipgloss.NewStyle(). + Foreground(sty.LogoGradTo). + Bold(true). + Render(" " + model + " ") + + fixed := lipgloss.Width(logoPart) + 1 + lipgloss.Width(modelStyled) + + var modePart string + if mode != "" { + modeBg := lipgloss.Color("#e06c75") + modeFg := lipgloss.Color("#c8ccd4") + if mode == "--VISUAL--" { + modeBg = lipgloss.Color("#c678dd") + } + modePart = lipgloss.NewStyle(). + Background(modeBg). + Foreground(modeFg). + Bold(true). + Padding(0, 1). + Render(mode) + " " + fixed += lipgloss.Width(modePart) + } + + gap := width - fixed + if gap < 0 { + gap = 0 + } + return sty.HeaderWrap.Width(width).Render( + modePart + logoPart + " " + modelStyled + strings.Repeat(" ", gap), + ) +} + +func wrapText(text string, width int) string { + if width <= 0 { + return text + } + var b strings.Builder + lines := strings.Split(text, "\n") + for li, line := range lines { + if li > 0 { + b.WriteByte('\n') + } + runes := []rune(line) + pos := 0 + for pos < len(runes) { + end := pos + width + if end > len(runes) { + end = len(runes) + } + b.WriteString(string(runes[pos:end])) + pos = end + if pos < len(runes) { + b.WriteByte('\n') + } + } + } + return b.String() +} + +func renderScrollbar(height, contentSize, viewportSize, offset int, thumb, track lipgloss.Style) string { + if height <= 0 || contentSize <= viewportSize { + return "" + } + thumbSize := max(1, height*viewportSize/contentSize) + maxOffset := contentSize - viewportSize + if maxOffset <= 0 { + return "" + } + trackSpace := height - thumbSize + thumbPos := 0 + if trackSpace > 0 && maxOffset > 0 { + thumbPos = min(trackSpace, offset*trackSpace/maxOffset) + } + var sb strings.Builder + for i := range height { + if i > 0 { + sb.WriteByte('\n') + } + if i >= thumbPos && i < thumbPos+thumbSize { + sb.WriteString(thumb.Render("┃")) + } else { + sb.WriteString(track.Render("│")) + } + } + return sb.String() +} + +func dialogTitle(title string, gradientAccent lipgloss.Style, width int, from, to color.Color) string { + titleRendered := lipgloss.NewStyle().Foreground(from).Render(title) + remaining := width - lipgloss.Width(titleRendered) - 1 + if remaining < 0 { + remaining = 0 + } + diagLine := applyForegroundGrad(gradientAccent, strings.Repeat("╱", remaining), from, to) + return titleRendered + " " + diagLine +} diff --git a/tui-ai-chats b/tui-ai-chats new file mode 100755 index 0000000..e45cadc Binary files /dev/null and b/tui-ai-chats differ