Files
Memoh/internal/tui/tui.go
T
晨苒 d2449cd345 reactor(cli): move memoh cli to tui
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
2026-04-14 01:21:03 +08:00

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
}