package main import ( "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" "time" "charm.land/bubbles/v2/list" ) type SessionData struct { ID string `json:"id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Title string `json:"title"` Provider string `json:"provider"` Model string `json:"model"` Messages []message `json:"messages"` History []string `json:"history"` } type sessionFile struct { Session SessionData `json:"session"` Deleted bool `json:"-"` } func currentSession(m *chatbot) *SessionData { if m.sessionIdx < 0 || m.sessionIdx >= len(m.sessions) { return nil } return &m.sessions[m.sessionIdx] } func dataDir() string { dir := os.Getenv("XDG_DATA_HOME") if dir == "" { home, _ := os.UserHomeDir() dir = filepath.Join(home, ".local", "share") } return filepath.Join(dir, "ludmila-ai-tui") } func sessionsDir() string { return filepath.Join(dataDir(), "sessions") } func sessionPath(id string) string { return filepath.Join(sessionsDir(), id+".json") } func ListSessions() ([]SessionData, error) { dir := sessionsDir() entries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("read sessions dir: %w", err) } var sessions []SessionData for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { continue } data, err := os.ReadFile(filepath.Join(dir, entry.Name())) if err != nil { continue } var file sessionFile if err := json.Unmarshal(data, &file); err != nil { continue } if file.Deleted { continue } sessions = append(sessions, file.Session) } sort.Slice(sessions, func(i, j int) bool { return sessions[i].UpdatedAt.After(sessions[j].UpdatedAt) }) return sessions, nil } func SaveSession(s SessionData) error { dir := sessionsDir() if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("create sessions dir: %w", err) } s.UpdatedAt = time.Now() file := sessionFile{Session: s} data, err := json.MarshalIndent(file, "", " ") if err != nil { return fmt.Errorf("marshal session: %w", err) } if err := os.WriteFile(sessionPath(s.ID), data, 0o644); err != nil { return fmt.Errorf("write session: %w", err) } return nil } func DeleteSession(id string) error { data, err := os.ReadFile(sessionPath(id)) if err != nil { return fmt.Errorf("read session: %w", err) } var file sessionFile if err := json.Unmarshal(data, &file); err != nil { return fmt.Errorf("unmarshal session: %w", err) } file.Deleted = true updatedData, err := json.MarshalIndent(file, "", " ") if err != nil { return fmt.Errorf("marshal deleted session: %w", err) } return os.WriteFile(sessionPath(id), updatedData, 0o644) } func NewSessionData(provider, model string) SessionData { now := time.Now() return SessionData{ ID: now.Format("20060102_150405"), CreatedAt: now, UpdatedAt: now, Title: "New session", Provider: provider, Model: model, } } func sessionTitle(s SessionData) string { if s.Title != "" && s.Title != "New session" { return s.Title } return "New session" } func (m *chatbot) saveCurrentSession() { if cs := currentSession(m); cs != nil { cs.UpdatedAt = time.Now() cs.Messages = m.messages cs.History = m.history cs.Provider = m.currentProvider cs.Model = m.currentModel if len(cs.Messages) > 0 && (cs.Title == "" || cs.Title == "New session") { cs.Title = sessionTitleFromMessages(cs.Messages) } m.sessions[m.sessionIdx] = *cs _ = SaveSession(*cs) } } func sessionTitleFromMessages(msgs []message) string { if len(msgs) == 0 { return "New session" } title := strings.TrimSpace(msgs[0].Content) if idx := strings.IndexAny(title, ".\n"); idx > 0 { title = title[:idx] } runes := []rune(title) if len(runes) > 40 { title = string(runes[:37]) + "..." } return title } type sessionItem struct { session SessionData active bool } func (s sessionItem) Title() string { prefix := " " if s.active { prefix = "* " } return prefix + sessionTitle(s.session) } func (s sessionItem) Description() string { parts := []string{ s.session.Provider + "/" + s.session.Model, "(" + fmt.Sprintf("%d msgs", len(s.session.Messages)) + ")", s.session.UpdatedAt.Format("Jan 02 15:04"), } return strings.Join(parts, " ยท ") } func (s sessionItem) FilterValue() string { return s.session.Title + " " + s.session.Provider + " " + s.session.Model } func buildSessionItems(sessions []SessionData, activeIdx int) []list.Item { items := make([]list.Item, len(sessions)) for i, s := range sessions { items[i] = sessionItem{session: s, active: i == activeIdx} } return items }