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
This commit is contained in:
晨苒
2026-04-14 00:39:34 +08:00
parent 8c9f222783
commit d50eeea114
32 changed files with 2140 additions and 1962 deletions
+26 -13
View File
@@ -2,23 +2,36 @@ package containerd
import (
"os"
"path/filepath"
"strings"
)
// TimezoneSpec returns mount specs and environment variables that propagate the host
// timezone into the container via /etc/localtime bind-mount and TZ environment variable.
// TimezoneSpec returns environment variables that propagate the host timezone
// into the container without relying on file bind mounts like /etc/localtime.
// File mounts can fail for workspace containers when the target path is absent
// in the unpacked rootfs, while TZ is sufficient for Go, Node, and most tools.
func TimezoneSpec() ([]MountSpec, []string) {
var mounts []MountSpec
var env []string
if _, err := os.Stat("/etc/localtime"); err == nil {
mounts = append(mounts, MountSpec{
Destination: "/etc/localtime",
Type: "bind",
Source: "/etc/localtime",
Options: []string{"rbind", "ro"},
})
}
if tz := os.Getenv("TZ"); tz != "" {
if tz := detectTimezone(); tz != "" {
env = append(env, "TZ="+tz)
}
return mounts, env
return nil, env
}
func detectTimezone() string {
if tz := strings.TrimSpace(os.Getenv("TZ")); tz != "" {
return tz
}
if data, err := os.ReadFile("/etc/timezone"); err == nil {
if tz := strings.TrimSpace(string(data)); tz != "" {
return tz
}
}
if target, err := filepath.EvalSymlinks("/etc/localtime"); err == nil {
const zoneinfoPrefix = "/usr/share/zoneinfo/"
if strings.HasPrefix(target, zoneinfoPrefix) {
return strings.TrimPrefix(target, zoneinfoPrefix)
}
}
return ""
}
+5 -11
View File
@@ -1,17 +1,14 @@
package containerd
import (
"os"
"testing"
)
func TestTimezoneSpec_WithTZ(t *testing.T) {
t.Setenv("TZ", "Asia/Shanghai")
mounts, env := TimezoneSpec()
if _, err := os.Stat("/etc/localtime"); err == nil {
if len(mounts) < 1 {
t.Fatal("expected at least one mount when /etc/localtime exists")
}
if len(mounts) != 0 {
t.Fatalf("expected no mounts, got %d", len(mounts))
}
if len(env) == 0 {
t.Fatal("expected at least one env var when TZ is set")
@@ -20,11 +17,8 @@ func TestTimezoneSpec_WithTZ(t *testing.T) {
func TestTimezoneSpec_WithoutTZ(t *testing.T) {
t.Setenv("TZ", "")
mounts, env := TimezoneSpec()
if len(env) != 0 {
t.Fatalf("expected no env when TZ empty, got %d", len(env))
}
if _, err := os.Stat("/etc/localtime"); err != nil && len(mounts) != 0 {
t.Fatalf("expected no mounts when /etc/localtime absent and TZ empty, got %d", len(mounts))
mounts, _ := TimezoneSpec()
if len(mounts) != 0 {
t.Fatalf("expected no mounts, got %d", len(mounts))
}
}
+30
View File
@@ -14,6 +14,11 @@ import (
"github.com/memohai/memoh/internal/config"
)
type MigrationStatus struct {
Version uint
Dirty bool
}
// RunMigrate applies or rolls back database migrations.
// The migrationsFS should contain .sql files at its root (not in a subdirectory).
// Supported commands: "up", "down", "version", "force N".
@@ -76,6 +81,31 @@ func RunMigrate(logger *slog.Logger, cfg config.PostgresConfig, migrationsFS fs.
return nil
}
func ReadMigrationStatus(cfg config.PostgresConfig, migrationsFS fs.FS) (MigrationStatus, error) {
sourceDriver, err := iofs.New(migrationsFS, ".")
if err != nil {
return MigrationStatus{}, fmt.Errorf("migration source: %w", err)
}
m, err := migrate.NewWithSourceInstance("iofs", sourceDriver, DSN(cfg))
if err != nil {
return MigrationStatus{}, fmt.Errorf("migrate init: %w", err)
}
defer func() { _, _ = m.Close() }()
ver, dirty, err := m.Version()
if err != nil {
if errors.Is(err, migrate.ErrNilVersion) {
return MigrationStatus{}, nil
}
return MigrationStatus{}, fmt.Errorf("migrate version: %w", err)
}
return MigrationStatus{
Version: ver,
Dirty: dirty,
}, nil
}
type migrateLogger struct {
logger *slog.Logger
}
+249
View File
@@ -0,0 +1,249 @@
package tui
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
"github.com/memohai/memoh/internal/bots"
"github.com/memohai/memoh/internal/conversation"
messagepkg "github.com/memohai/memoh/internal/message"
"github.com/memohai/memoh/internal/session"
)
type Client struct {
BaseURL string
Token string
HTTPClient *http.Client
}
type LoginResponse struct {
AccessToken string `json:"access_token"` //nolint:gosec // CLI needs to persist and reuse the JWT access token
TokenType string `json:"token_type"`
ExpiresAt string `json:"expires_at"`
UserID string `json:"user_id"`
Role string `json:"role"`
DisplayName string `json:"display_name"`
Username string `json:"username"`
Timezone string `json:"timezone,omitempty"`
}
type ChatRequest struct {
BotID string
SessionID string
Text string
ModelID string
ReasoningEffort string
}
type ChatEvent struct {
Type string
Message string
Data conversation.UIMessage
}
func NewClient(baseURL, token string) *Client {
return &Client{
BaseURL: NormalizeServerURL(baseURL),
Token: strings.TrimSpace(token),
HTTPClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
func (c *Client) Login(ctx context.Context, username, password string) (LoginResponse, error) {
var resp LoginResponse
err := c.doJSON(ctx, http.MethodPost, "/auth/login", map[string]string{
"username": username,
"password": password,
}, &resp)
return resp, err
}
func (c *Client) ListBots(ctx context.Context) ([]bots.Bot, error) {
var resp bots.ListBotsResponse
err := c.doJSON(ctx, http.MethodGet, "/bots", nil, &resp)
return resp.Items, err
}
func (c *Client) ListSessions(ctx context.Context, botID string) ([]session.Session, error) {
var resp struct {
Items []session.Session `json:"items"`
}
err := c.doJSON(ctx, http.MethodGet, fmt.Sprintf("/bots/%s/sessions", botID), nil, &resp)
return resp.Items, err
}
func (c *Client) CreateSession(ctx context.Context, botID, title string) (session.Session, error) {
var resp session.Session
err := c.doJSON(ctx, http.MethodPost, fmt.Sprintf("/bots/%s/sessions", botID), map[string]string{
"title": title,
}, &resp)
return resp, err
}
func (c *Client) ListMessages(ctx context.Context, botID, sessionID string) ([]conversation.UITurn, error) {
path := fmt.Sprintf("/bots/%s/messages?format=ui", botID)
if strings.TrimSpace(sessionID) != "" {
path += "&session_id=" + url.QueryEscape(sessionID)
}
var resp struct {
Items []conversation.UITurn `json:"items"`
}
err := c.doJSON(ctx, http.MethodGet, path, nil, &resp)
return resp.Items, err
}
func (c *Client) ListRawMessages(ctx context.Context, botID, sessionID string) ([]messagepkg.Message, error) {
path := fmt.Sprintf("/bots/%s/messages", botID)
if strings.TrimSpace(sessionID) != "" {
path += "?session_id=" + url.QueryEscape(sessionID)
}
var resp struct {
Items []messagepkg.Message `json:"items"`
}
err := c.doJSON(ctx, http.MethodGet, path, nil, &resp)
return resp.Items, err
}
func (c *Client) StreamChat(ctx context.Context, req ChatRequest, onEvent func(ChatEvent) error) error {
if strings.TrimSpace(c.Token) == "" {
return errors.New("missing access token")
}
u, err := url.Parse(c.BaseURL)
if err != nil {
return fmt.Errorf("parse base url: %w", err)
}
switch u.Scheme {
case "http":
u.Scheme = "ws"
case "https":
u.Scheme = "wss"
}
u.Path = fmt.Sprintf("/bots/%s/web/ws", req.BotID)
q := u.Query()
q.Set("token", c.Token)
u.RawQuery = q.Encode()
conn, resp, err := websocket.Dial(ctx, u.String(), nil)
if err != nil {
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
}
return fmt.Errorf("dial websocket: %w", err)
}
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
}
defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }()
payload := map[string]string{
"type": "message",
"text": req.Text,
"session_id": req.SessionID,
}
if strings.TrimSpace(req.ModelID) != "" {
payload["model_id"] = req.ModelID
}
if strings.TrimSpace(req.ReasoningEffort) != "" {
payload["reasoning_effort"] = req.ReasoningEffort
}
if err := wsjson.Write(ctx, conn, payload); err != nil {
return fmt.Errorf("write websocket request: %w", err)
}
for {
var envelope struct {
Type string `json:"type"`
Message string `json:"message,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
}
if err := wsjson.Read(ctx, conn, &envelope); err != nil {
if websocket.CloseStatus(err) == websocket.StatusNormalClosure {
return nil
}
return fmt.Errorf("read websocket event: %w", err)
}
switch envelope.Type {
case "start", "end":
if err := onEvent(ChatEvent{Type: envelope.Type}); err != nil {
return err
}
if envelope.Type == "end" {
return nil
}
case "error":
if err := onEvent(ChatEvent{Type: "error", Message: envelope.Message}); err != nil {
return err
}
return errors.New(strings.TrimSpace(envelope.Message))
case "message":
var uiMessage conversation.UIMessage
if err := json.Unmarshal(envelope.Data, &uiMessage); err != nil {
return fmt.Errorf("decode chat message: %w", err)
}
if err := onEvent(ChatEvent{Type: "message", Data: uiMessage}); err != nil {
return err
}
}
}
}
func (c *Client) doJSON(ctx context.Context, method, path string, body any, out any) error {
var reader io.Reader
if body != nil {
payload, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
reader = bytes.NewReader(payload)
}
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, reader)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if strings.TrimSpace(c.Token) != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
resp, err := c.HTTPClient.Do(req) //nolint:gosec // BaseURL is user-controlled CLI config by design
if err != nil {
return fmt.Errorf("perform request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
data, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode >= 400 {
message := strings.TrimSpace(string(data))
if message == "" {
message = resp.Status
}
return fmt.Errorf("%s", message)
}
if out == nil || len(data) == 0 {
return nil
}
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("decode response: %w", err)
}
return nil
}
+90
View File
@@ -0,0 +1,90 @@
package tui
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
const (
DefaultProdServerURL = "http://127.0.0.1:8080"
DefaultDevServerURL = "http://127.0.0.1:18080"
)
type State struct {
ServerURL string `json:"server_url"`
Token string `json:"token,omitempty"`
Username string `json:"username,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
}
func DefaultState() State {
return State{ServerURL: DefaultProdServerURL}
}
func LoadState() (State, error) {
path, err := statePath()
if err != nil {
return State{}, err
}
data, err := os.ReadFile(path) //nolint:gosec // path is derived from the user's config directory, not arbitrary input
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return DefaultState(), nil
}
return State{}, fmt.Errorf("read state: %w", err)
}
state := DefaultState()
if err := json.Unmarshal(data, &state); err != nil {
return State{}, fmt.Errorf("decode state: %w", err)
}
state.ServerURL = NormalizeServerURL(state.ServerURL)
return state, nil
}
func SaveState(state State) error {
path, err := statePath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
if strings.TrimSpace(state.ServerURL) == "" {
state.ServerURL = DefaultProdServerURL
}
state.ServerURL = NormalizeServerURL(state.ServerURL)
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return fmt.Errorf("encode state: %w", err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
return fmt.Errorf("write state: %w", err)
}
return nil
}
func NormalizeServerURL(raw string) string {
trimmed := strings.TrimRight(strings.TrimSpace(raw), "/")
if trimmed == "" {
return DefaultProdServerURL
}
if !strings.Contains(trimmed, "://") {
return "http://" + trimmed
}
return trimmed
}
func statePath() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("resolve user config dir: %w", err)
}
return filepath.Join(dir, "memoh", "cli.json"), nil
}
+27
View File
@@ -0,0 +1,27 @@
package tui
import (
"fmt"
"io/fs"
"os"
dbembed "github.com/memohai/memoh/db"
"github.com/memohai/memoh/internal/config"
)
func ProvideConfig() (config.Config, error) {
cfgPath := os.Getenv("CONFIG_PATH")
cfg, err := config.Load(cfgPath)
if err != nil {
return config.Config{}, fmt.Errorf("load config: %w", err)
}
return cfg, nil
}
func MigrationsFS() fs.FS {
sub, err := fs.Sub(dbembed.MigrationsFS, "migrations")
if err != nil {
panic(fmt.Sprintf("embedded migrations: %v", err))
}
return sub
}
+844
View File
@@ -0,0 +1,844 @@
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
}