mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
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:
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user