mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
d2449cd345
1. Split the oversized `cmd/agent` entrypoint into a multi-file package and update dev/build scripts to use the package path instead of compiling `main.go` directly. 2. Add a new `memoh` terminal UI for local bot chat, with Bubble Tea
845 lines
20 KiB
Go
845 lines
20 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/list"
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/glamour"
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"github.com/memohai/memoh/internal/conversation"
|
|
dbpkg "github.com/memohai/memoh/internal/db"
|
|
"github.com/memohai/memoh/internal/session"
|
|
)
|
|
|
|
type focusArea int
|
|
|
|
const (
|
|
focusBots focusArea = iota
|
|
focusSessions
|
|
focusChat
|
|
focusInput
|
|
)
|
|
|
|
type TUIModel struct {
|
|
client *Client
|
|
state State
|
|
|
|
panelWidth int
|
|
width int
|
|
height int
|
|
|
|
focus focusArea
|
|
|
|
bots []botSummary
|
|
botList list.Model
|
|
sessions []session.Session
|
|
sessList list.Model
|
|
|
|
input textinput.Model
|
|
viewport viewport.Model
|
|
|
|
status string
|
|
dbStatus string
|
|
|
|
chatContent string
|
|
viewportContent string
|
|
streamPreview string
|
|
streamPreviewOrder []int
|
|
streamPreviewItems map[int]conversation.UIMessage
|
|
streamCh <-chan ChatEvent
|
|
}
|
|
|
|
type botSummary struct {
|
|
ID string
|
|
DisplayName string
|
|
Status string
|
|
}
|
|
|
|
type selectorItem struct {
|
|
id string
|
|
title string
|
|
}
|
|
|
|
func (i selectorItem) FilterValue() string { return i.title + " " + i.id }
|
|
func (i selectorItem) Title() string { return i.title }
|
|
func (selectorItem) Description() string { return "" }
|
|
|
|
var (
|
|
memohPrimary = lipgloss.AdaptiveColor{Light: "#7C3AED", Dark: "#A78BFA"}
|
|
mutedBorder = lipgloss.AdaptiveColor{Light: "#D7D8E0", Dark: "#3A3442"}
|
|
mutedTitle = lipgloss.AdaptiveColor{Light: "#666978", Dark: "#9A97A3"}
|
|
)
|
|
|
|
type dbStatusMsg struct {
|
|
value string
|
|
err error
|
|
}
|
|
|
|
type botsLoadedMsg struct {
|
|
items []botSummary
|
|
err error
|
|
}
|
|
|
|
type sessionsLoadedMsg struct {
|
|
items []session.Session
|
|
err error
|
|
}
|
|
|
|
type turnsLoadedMsg struct {
|
|
content string
|
|
err error
|
|
}
|
|
|
|
type chatStartedMsg struct {
|
|
sessionID string
|
|
streamCh <-chan ChatEvent
|
|
err error
|
|
}
|
|
|
|
type chatEventMsg struct {
|
|
event ChatEvent
|
|
}
|
|
|
|
type chatDoneMsg struct{}
|
|
|
|
func NewTUIModel(state State) *TUIModel {
|
|
input := textinput.New()
|
|
input.Placeholder = "Type a message and press Enter"
|
|
input.Focus()
|
|
|
|
botList := newSelectorList()
|
|
sessList := newSelectorList()
|
|
|
|
return &TUIModel{
|
|
client: NewClient(state.ServerURL, state.Token),
|
|
state: state,
|
|
focus: focusBots,
|
|
input: input,
|
|
viewport: viewport.New(80, 20),
|
|
status: "Loading environment status...",
|
|
dbStatus: "checking",
|
|
botList: botList,
|
|
sessList: sessList,
|
|
streamPreviewItems: map[int]conversation.UIMessage{},
|
|
}
|
|
}
|
|
|
|
func (m *TUIModel) Init() tea.Cmd {
|
|
return tea.Batch(
|
|
loadDBStatusCmd(),
|
|
loadBotsCmd(m.client),
|
|
)
|
|
}
|
|
|
|
func (m *TUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
m.panelWidth = max(30, msg.Width-6)
|
|
listWidth := max(20, m.panelWidth-2)
|
|
chatWidth := max(18, m.panelWidth-4)
|
|
listHeight := max(4, min(8, max(4, msg.Height/5)))
|
|
m.botList.SetSize(listWidth, listHeight)
|
|
m.sessList.SetSize(listWidth, listHeight)
|
|
m.viewport.Width = chatWidth
|
|
m.viewport.Height = max(8, msg.Height-(listHeight*2)-14)
|
|
m.input.Width = listWidth
|
|
m.syncViewport(false)
|
|
return m, nil
|
|
|
|
case dbStatusMsg:
|
|
if msg.err != nil {
|
|
m.dbStatus = "unavailable"
|
|
m.status = "DB status unavailable: " + msg.err.Error()
|
|
} else {
|
|
m.dbStatus = msg.value
|
|
}
|
|
return m, nil
|
|
|
|
case botsLoadedMsg:
|
|
if msg.err != nil {
|
|
m.status = "Failed to load bots: " + msg.err.Error()
|
|
return m, nil
|
|
}
|
|
m.bots = msg.items
|
|
m.syncBotList()
|
|
if len(m.bots) > 0 {
|
|
m.status = "Use Tab to switch focus. Enter on bots/sessions. Enter in input sends."
|
|
return m, loadSessionsCmd(m.client, m.currentBotID())
|
|
}
|
|
if strings.TrimSpace(m.state.Token) == "" {
|
|
m.status = "Login first with `memoh login`, then reopen the TUI."
|
|
} else {
|
|
m.status = "No accessible bots found."
|
|
}
|
|
return m, nil
|
|
|
|
case sessionsLoadedMsg:
|
|
if msg.err != nil {
|
|
m.status = "Failed to load sessions: " + msg.err.Error()
|
|
return m, nil
|
|
}
|
|
m.sessions = msg.items
|
|
m.syncSessionList()
|
|
if current := m.currentSessionID(); current != "" {
|
|
return m, loadTurnsCmd(m.client, m.currentBotID(), current)
|
|
}
|
|
m.chatContent = ""
|
|
m.clearStreamPreview()
|
|
m.viewport.SetContent("")
|
|
return m, nil
|
|
|
|
case turnsLoadedMsg:
|
|
if msg.err != nil {
|
|
m.status = "Failed to load messages: " + msg.err.Error()
|
|
return m, nil
|
|
}
|
|
m.chatContent = msg.content
|
|
m.clearStreamPreview()
|
|
m.syncViewport(true)
|
|
return m, nil
|
|
|
|
case chatStartedMsg:
|
|
if msg.err != nil {
|
|
m.status = "Failed to start chat: " + msg.err.Error()
|
|
return m, nil
|
|
}
|
|
m.streamCh = msg.streamCh
|
|
m.clearStreamPreview()
|
|
if msg.sessionID != "" && msg.sessionID != m.currentSessionID() {
|
|
return m, tea.Batch(
|
|
loadSessionsCmd(m.client, m.currentBotID()),
|
|
waitForChatEventCmd(msg.streamCh),
|
|
)
|
|
}
|
|
return m, waitForChatEventCmd(msg.streamCh)
|
|
|
|
case chatEventMsg:
|
|
switch msg.event.Type {
|
|
case "start":
|
|
m.status = "Streaming reply..."
|
|
case "message":
|
|
m.updateStreamPreview(msg.event.Data)
|
|
m.syncViewport(true)
|
|
case "error":
|
|
m.status = "Chat error: " + msg.event.Message
|
|
case "end":
|
|
m.status = "Reply finished."
|
|
}
|
|
return m, waitForChatEventCmd(m.streamCh)
|
|
|
|
case chatDoneMsg:
|
|
m.streamCh = nil
|
|
return m, loadTurnsCmd(m.client, m.currentBotID(), m.currentSessionID())
|
|
|
|
case tea.KeyMsg:
|
|
if m.focus == focusChat {
|
|
switch msg.String() {
|
|
case "ctrl+c":
|
|
return m, tea.Quit
|
|
case "tab":
|
|
m.focus = nextFocus(m.focus)
|
|
cmd := m.input.Focus()
|
|
return m, cmd
|
|
case "shift+tab":
|
|
m.focus = prevFocus(m.focus)
|
|
return m, nil
|
|
case "esc", "q":
|
|
m.focus = focusInput
|
|
cmd := m.input.Focus()
|
|
return m, cmd
|
|
case "down", "j":
|
|
m.viewport.ScrollDown(1)
|
|
return m, nil
|
|
case "up", "k":
|
|
m.viewport.ScrollUp(1)
|
|
return m, nil
|
|
case "pgdown", "f":
|
|
m.viewport.PageDown()
|
|
return m, nil
|
|
case "pgup", "b":
|
|
m.viewport.PageUp()
|
|
return m, nil
|
|
case "ctrl+d":
|
|
m.viewport.HalfPageDown()
|
|
return m, nil
|
|
case "ctrl+u":
|
|
m.viewport.HalfPageUp()
|
|
return m, nil
|
|
case "home", "g":
|
|
m.viewport.GotoTop()
|
|
return m, nil
|
|
case "end", "G":
|
|
m.viewport.GotoBottom()
|
|
return m, nil
|
|
}
|
|
var cmd tea.Cmd
|
|
m.viewport, cmd = m.viewport.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
if m.focus == focusInput {
|
|
switch msg.String() {
|
|
case "ctrl+c":
|
|
return m, tea.Quit
|
|
case "tab":
|
|
m.focus = nextFocus(m.focus)
|
|
m.input.Blur()
|
|
return m, nil
|
|
case "shift+tab":
|
|
m.focus = prevFocus(m.focus)
|
|
m.input.Blur()
|
|
return m, nil
|
|
case "esc":
|
|
m.focus = focusChat
|
|
m.input.Blur()
|
|
return m, nil
|
|
case "enter":
|
|
text := strings.TrimSpace(m.input.Value())
|
|
if text == "" {
|
|
return m, nil
|
|
}
|
|
if m.currentBotID() == "" {
|
|
m.status = "Select a bot first."
|
|
return m, nil
|
|
}
|
|
m.appendTranscript(renderTurnMarkdown(conversation.UITurn{
|
|
Role: "user",
|
|
Text: text,
|
|
}))
|
|
m.input.SetValue("")
|
|
return m, startChatCmd(m.client, m.currentBotID(), m.currentSessionID(), text)
|
|
default:
|
|
var cmd tea.Cmd
|
|
m.input, cmd = m.input.Update(msg)
|
|
return m, cmd
|
|
}
|
|
}
|
|
|
|
switch msg.String() {
|
|
case "ctrl+c", "q":
|
|
return m, tea.Quit
|
|
case "tab":
|
|
m.focus = nextFocus(m.focus)
|
|
if m.focus == focusInput {
|
|
cmd := m.input.Focus()
|
|
return m, cmd
|
|
}
|
|
m.input.Blur()
|
|
return m, nil
|
|
case "shift+tab":
|
|
m.focus = prevFocus(m.focus)
|
|
if m.focus == focusInput {
|
|
cmd := m.input.Focus()
|
|
return m, cmd
|
|
}
|
|
m.input.Blur()
|
|
return m, nil
|
|
case "enter":
|
|
switch m.focus {
|
|
case focusBots:
|
|
return m, loadSessionsCmd(m.client, m.currentBotID())
|
|
case focusSessions:
|
|
if current := m.currentSessionID(); current != "" {
|
|
return m, loadTurnsCmd(m.client, m.currentBotID(), current)
|
|
}
|
|
return m, nil
|
|
case focusChat:
|
|
m.viewport.GotoBottom()
|
|
return m, nil
|
|
}
|
|
case "up", "k":
|
|
// handled by list models below when focused
|
|
case "down", "j":
|
|
// handled by list models below when focused
|
|
}
|
|
}
|
|
|
|
switch m.focus {
|
|
case focusBots:
|
|
var cmd tea.Cmd
|
|
m.botList, cmd = m.botList.Update(msg)
|
|
return m, cmd
|
|
case focusSessions:
|
|
var cmd tea.Cmd
|
|
m.sessList, cmd = m.sessList.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
m.viewport, cmd = m.viewport.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
func (m *TUIModel) View() string {
|
|
header := lipgloss.NewStyle().Bold(true).Render("memoh terminal ui")
|
|
status := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(m.status)
|
|
focusHint := "focus=bots"
|
|
switch m.focus {
|
|
case focusSessions:
|
|
focusHint = "focus=sessions"
|
|
case focusChat:
|
|
focusHint = "focus=chat (j/k pgup/pgdn ctrl+u/d g/G esc)"
|
|
case focusInput:
|
|
focusHint = "focus=input"
|
|
}
|
|
envBlock := panel("Status", strings.Join([]string{
|
|
header,
|
|
"server: " + m.client.BaseURL,
|
|
"db: " + emptyFallback(m.dbStatus, "checking"),
|
|
focusHint,
|
|
status,
|
|
}, "\n"), false, m.panelWidth)
|
|
|
|
return lipgloss.JoinVertical(lipgloss.Left,
|
|
envBlock,
|
|
panel("Bots", m.botList.View(), m.focus == focusBots, m.panelWidth),
|
|
panel("Sessions", m.sessList.View(), m.focus == focusSessions, m.panelWidth),
|
|
panel("Chat", m.renderChatViewport(), m.focus == focusChat, m.panelWidth),
|
|
panel("Input", m.input.View(), m.focus == focusInput, m.panelWidth),
|
|
)
|
|
}
|
|
|
|
func loadDBStatusCmd() tea.Cmd {
|
|
return func() tea.Msg {
|
|
cfg, err := ProvideConfig()
|
|
if err != nil {
|
|
return dbStatusMsg{err: err}
|
|
}
|
|
status, err := dbpkg.ReadMigrationStatus(cfg.Postgres, MigrationsFS())
|
|
if err != nil {
|
|
return dbStatusMsg{err: err}
|
|
}
|
|
return dbStatusMsg{value: fmt.Sprintf("version=%d dirty=%t", status.Version, status.Dirty)}
|
|
}
|
|
}
|
|
|
|
func loadBotsCmd(client *Client) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if strings.TrimSpace(client.Token) == "" {
|
|
return botsLoadedMsg{}
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
items, err := client.ListBots(ctx)
|
|
if err != nil {
|
|
return botsLoadedMsg{err: err}
|
|
}
|
|
result := make([]botSummary, 0, len(items))
|
|
for _, item := range items {
|
|
result = append(result, botSummary{
|
|
ID: item.ID,
|
|
DisplayName: item.DisplayName,
|
|
Status: item.Status,
|
|
})
|
|
}
|
|
return botsLoadedMsg{items: result}
|
|
}
|
|
}
|
|
|
|
func loadSessionsCmd(client *Client, botID string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if strings.TrimSpace(botID) == "" {
|
|
return sessionsLoadedMsg{}
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
items, err := client.ListSessions(ctx, botID)
|
|
return sessionsLoadedMsg{items: items, err: err}
|
|
}
|
|
}
|
|
|
|
func loadTurnsCmd(client *Client, botID, sessionID string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if strings.TrimSpace(botID) == "" || strings.TrimSpace(sessionID) == "" {
|
|
return turnsLoadedMsg{}
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
items, err := client.ListMessages(ctx, botID, sessionID)
|
|
if err != nil {
|
|
return turnsLoadedMsg{err: err}
|
|
}
|
|
lines := make([]string, 0, len(items))
|
|
for _, turn := range items {
|
|
lines = append(lines, renderTurnMarkdown(turn))
|
|
}
|
|
return turnsLoadedMsg{content: strings.Join(lines, "\n\n")}
|
|
}
|
|
}
|
|
|
|
func startChatCmd(client *Client, botID, sessionID, text string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
|
|
activeSessionID := strings.TrimSpace(sessionID)
|
|
if activeSessionID == "" {
|
|
sess, err := client.CreateSession(ctx, botID, text)
|
|
if err != nil {
|
|
return chatStartedMsg{err: err}
|
|
}
|
|
activeSessionID = sess.ID
|
|
}
|
|
|
|
streamCh := make(chan ChatEvent, 32)
|
|
go func() {
|
|
defer close(streamCh)
|
|
err := client.StreamChat(context.Background(), ChatRequest{
|
|
BotID: botID,
|
|
SessionID: activeSessionID,
|
|
Text: text,
|
|
}, func(event ChatEvent) error {
|
|
streamCh <- event
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
streamCh <- ChatEvent{Type: "error", Message: err.Error()}
|
|
}
|
|
}()
|
|
|
|
return chatStartedMsg{
|
|
sessionID: activeSessionID,
|
|
streamCh: streamCh,
|
|
}
|
|
}
|
|
}
|
|
|
|
func waitForChatEventCmd(ch <-chan ChatEvent) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if ch == nil {
|
|
return chatDoneMsg{}
|
|
}
|
|
event, ok := <-ch
|
|
if !ok {
|
|
return chatDoneMsg{}
|
|
}
|
|
return chatEventMsg{event: event}
|
|
}
|
|
}
|
|
|
|
func newSelectorList() list.Model {
|
|
delegate := list.NewDefaultDelegate()
|
|
delegate.ShowDescription = false
|
|
delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle.
|
|
Foreground(memohPrimary).
|
|
BorderForeground(memohPrimary).
|
|
Bold(true)
|
|
delegate.Styles.NormalTitle = delegate.Styles.NormalTitle.Foreground(lipgloss.Color("252"))
|
|
delegate.Styles.DimmedTitle = delegate.Styles.DimmedTitle.Foreground(mutedTitle)
|
|
|
|
l := list.New([]list.Item{}, delegate, 0, 0)
|
|
l.SetShowTitle(false)
|
|
l.SetShowStatusBar(false)
|
|
l.SetShowHelp(false)
|
|
l.SetShowPagination(false)
|
|
l.SetFilteringEnabled(false)
|
|
l.DisableQuitKeybindings()
|
|
l.KeyMap.Quit.SetEnabled(false)
|
|
l.KeyMap.ForceQuit.SetEnabled(false)
|
|
l.Styles.NoItems = lipgloss.NewStyle().Foreground(mutedTitle)
|
|
return l
|
|
}
|
|
|
|
func (m *TUIModel) syncBotList() {
|
|
selectedID := m.currentBotID()
|
|
items := make([]list.Item, 0, len(m.bots))
|
|
selectedIdx := 0
|
|
for i, item := range m.bots {
|
|
entry := selectorItem{
|
|
id: item.ID,
|
|
title: item.DisplayName,
|
|
}
|
|
items = append(items, entry)
|
|
if item.ID == selectedID {
|
|
selectedIdx = i
|
|
}
|
|
}
|
|
m.botList.SetItems(items)
|
|
if len(items) > 0 {
|
|
m.botList.Select(selectedIdx)
|
|
}
|
|
}
|
|
|
|
func (m *TUIModel) syncSessionList() {
|
|
selectedID := m.currentSessionID()
|
|
items := make([]list.Item, 0, len(m.sessions))
|
|
selectedIdx := 0
|
|
for i, item := range m.sessions {
|
|
title := strings.TrimSpace(item.Title)
|
|
if title == "" {
|
|
title = item.ID
|
|
}
|
|
entry := selectorItem{
|
|
id: item.ID,
|
|
title: title,
|
|
}
|
|
items = append(items, entry)
|
|
if item.ID == selectedID {
|
|
selectedIdx = i
|
|
}
|
|
}
|
|
m.sessList.SetItems(items)
|
|
if len(items) > 0 {
|
|
m.sessList.Select(selectedIdx)
|
|
}
|
|
}
|
|
|
|
func (m *TUIModel) currentBotID() string {
|
|
item, ok := m.botList.SelectedItem().(selectorItem)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return item.id
|
|
}
|
|
|
|
func (m *TUIModel) currentSessionID() string {
|
|
item, ok := m.sessList.SelectedItem().(selectorItem)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return item.id
|
|
}
|
|
|
|
func (m *TUIModel) appendTranscript(line string) {
|
|
if strings.TrimSpace(m.chatContent) == "" {
|
|
m.chatContent = line
|
|
} else {
|
|
m.chatContent += "\n\n---\n\n" + line
|
|
}
|
|
m.syncViewport(true)
|
|
}
|
|
|
|
func (m *TUIModel) updateStreamPreview(msg conversation.UIMessage) {
|
|
if _, ok := m.streamPreviewItems[msg.ID]; !ok {
|
|
m.streamPreviewOrder = append(m.streamPreviewOrder, msg.ID)
|
|
}
|
|
m.streamPreviewItems[msg.ID] = msg
|
|
parts := make([]string, 0, len(m.streamPreviewOrder))
|
|
for _, id := range m.streamPreviewOrder {
|
|
item, ok := m.streamPreviewItems[id]
|
|
if !ok {
|
|
continue
|
|
}
|
|
rendered := strings.TrimSpace(renderStreamPreviewMessage(item))
|
|
if rendered == "" {
|
|
continue
|
|
}
|
|
parts = append(parts, rendered)
|
|
}
|
|
m.streamPreview = strings.Join(parts, "\n\n")
|
|
}
|
|
|
|
func (m *TUIModel) clearStreamPreview() {
|
|
m.streamPreview = ""
|
|
m.streamPreviewOrder = nil
|
|
m.streamPreviewItems = map[int]conversation.UIMessage{}
|
|
}
|
|
|
|
func renderStreamPreviewMessage(msg conversation.UIMessage) string {
|
|
switch msg.Type {
|
|
case conversation.UIMessageText:
|
|
return strings.TrimSpace(msg.Content)
|
|
case conversation.UIMessageReasoning:
|
|
content := strings.TrimSpace(msg.Content)
|
|
if content == "" {
|
|
return ""
|
|
}
|
|
return "[reasoning]\n" + content
|
|
case conversation.UIMessageTool:
|
|
state := "done"
|
|
if msg.Running != nil && *msg.Running {
|
|
state = "running"
|
|
}
|
|
return fmt.Sprintf("[tool:%s %s]", strings.TrimSpace(msg.Name), state)
|
|
case conversation.UIMessageAttachments:
|
|
return fmt.Sprintf("[attachments:%d]", len(msg.Attachments))
|
|
default:
|
|
return strings.TrimSpace(msg.Content)
|
|
}
|
|
}
|
|
|
|
func renderTurnMarkdown(turn conversation.UITurn) string {
|
|
header := "Assistant"
|
|
if strings.EqualFold(turn.Role, "user") {
|
|
header = "You"
|
|
}
|
|
if turn.SenderDisplayName != "" {
|
|
header = turn.SenderDisplayName
|
|
}
|
|
body := strings.TrimSpace(turn.Text)
|
|
if body == "" && len(turn.Messages) > 0 {
|
|
parts := make([]string, 0, len(turn.Messages))
|
|
for _, msg := range turn.Messages {
|
|
parts = append(parts, RenderUIMessageMarkdown(msg))
|
|
}
|
|
body = strings.Join(parts, "\n")
|
|
}
|
|
body = strings.TrimSpace(body)
|
|
if body == "" {
|
|
body = "_No content_"
|
|
}
|
|
return fmt.Sprintf("## %s\n\n%s", header, body)
|
|
}
|
|
|
|
func RenderUIMessage(msg conversation.UIMessage) string {
|
|
return renderMarkdownToANSI(RenderUIMessageMarkdown(msg), 0)
|
|
}
|
|
|
|
func RenderUIMessageMarkdown(msg conversation.UIMessage) string {
|
|
switch msg.Type {
|
|
case conversation.UIMessageText:
|
|
return strings.TrimSpace(msg.Content)
|
|
case conversation.UIMessageReasoning:
|
|
return fmt.Sprintf("> Reasoning\n>\n> %s", strings.ReplaceAll(strings.TrimSpace(msg.Content), "\n", "\n> "))
|
|
case conversation.UIMessageTool:
|
|
state := "done"
|
|
if msg.Running != nil && *msg.Running {
|
|
state = "running"
|
|
}
|
|
return fmt.Sprintf("**Tool:** `%s` (%s)", strings.TrimSpace(msg.Name), state)
|
|
case conversation.UIMessageAttachments:
|
|
return fmt.Sprintf("**Attachments:** %d", len(msg.Attachments))
|
|
default:
|
|
return strings.TrimSpace(msg.Content)
|
|
}
|
|
}
|
|
|
|
func (m *TUIModel) syncViewport(gotoBottom bool) {
|
|
base := renderMarkdownToANSI(m.chatContent, m.viewport.Width)
|
|
preview := strings.TrimSpace(m.streamPreview)
|
|
content := ""
|
|
switch {
|
|
case base == "":
|
|
content = preview
|
|
case preview == "":
|
|
content = base
|
|
default:
|
|
content = base + "\n\n" + preview
|
|
}
|
|
m.viewportContent = content
|
|
m.viewport.SetContent(content)
|
|
if gotoBottom {
|
|
m.viewport.GotoBottom()
|
|
}
|
|
}
|
|
|
|
func (m *TUIModel) renderChatViewport() string {
|
|
view := m.viewport.View()
|
|
lines := strings.Split(view, "\n")
|
|
if len(lines) == 0 {
|
|
lines = []string{""}
|
|
}
|
|
|
|
height := max(1, m.viewport.Height)
|
|
if len(lines) < height {
|
|
padded := make([]string, height)
|
|
copy(padded, lines)
|
|
for i := len(lines); i < height; i++ {
|
|
padded[i] = ""
|
|
}
|
|
lines = padded
|
|
} else if len(lines) > height {
|
|
lines = lines[:height]
|
|
}
|
|
|
|
totalLines := 0
|
|
if strings.TrimSpace(m.viewportContent) != "" {
|
|
totalLines = len(strings.Split(m.viewportContent, "\n"))
|
|
}
|
|
if totalLines <= height {
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
thumbHeight := max(1, int(math.Round(float64(height*height)/float64(totalLines))))
|
|
maxOffset := max(1, totalLines-height)
|
|
thumbTop := int(math.Round(float64(m.viewport.YOffset) / float64(maxOffset) * float64(height-thumbHeight)))
|
|
if thumbTop < 0 {
|
|
thumbTop = 0
|
|
}
|
|
if thumbTop > height-thumbHeight {
|
|
thumbTop = height - thumbHeight
|
|
}
|
|
|
|
railStyle := lipgloss.NewStyle().Foreground(mutedTitle)
|
|
thumbStyle := lipgloss.NewStyle().Foreground(memohPrimary).Bold(true)
|
|
withBar := make([]string, 0, len(lines))
|
|
for i, line := range lines {
|
|
bar := railStyle.Render("│")
|
|
if i >= thumbTop && i < thumbTop+thumbHeight {
|
|
bar = thumbStyle.Render("█")
|
|
}
|
|
withBar = append(withBar, line+" "+bar)
|
|
}
|
|
return strings.Join(withBar, "\n")
|
|
}
|
|
|
|
func renderMarkdownToANSI(markdown string, width int) string {
|
|
if strings.TrimSpace(markdown) == "" {
|
|
return ""
|
|
}
|
|
opts := []glamour.TermRendererOption{
|
|
glamour.WithStandardStyle("dark"),
|
|
}
|
|
if width > 0 {
|
|
opts = append(opts, glamour.WithWordWrap(width))
|
|
}
|
|
renderer, err := glamour.NewTermRenderer(opts...)
|
|
if err != nil {
|
|
return markdown
|
|
}
|
|
out, err := renderer.Render(markdown)
|
|
if err != nil {
|
|
return markdown
|
|
}
|
|
return strings.TrimRight(out, "\n")
|
|
}
|
|
|
|
func nextFocus(current focusArea) focusArea {
|
|
return (current + 1) % 4
|
|
}
|
|
|
|
func prevFocus(current focusArea) focusArea {
|
|
if current == 0 {
|
|
return 3
|
|
}
|
|
return current - 1
|
|
}
|
|
|
|
func panel(title, body string, focused bool, width int) string {
|
|
borderColor := mutedBorder
|
|
titleColor := mutedTitle
|
|
if focused {
|
|
borderColor = memohPrimary
|
|
titleColor = memohPrimary
|
|
}
|
|
|
|
titleLine := lipgloss.NewStyle().
|
|
Bold(focused).
|
|
Foreground(titleColor).
|
|
Render(title)
|
|
|
|
style := lipgloss.NewStyle().
|
|
Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(borderColor).
|
|
Width(width).
|
|
Padding(0, 1)
|
|
return style.Render(titleLine + "\n" + body)
|
|
}
|
|
|
|
func emptyFallback(value, fallback string) string {
|
|
if strings.TrimSpace(value) == "" {
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|