From e4c75c7c0e411c7af9aca598ee3b7664444f23ce Mon Sep 17 00:00:00 2001 From: loveuer Date: Tue, 23 Jun 2026 17:49:36 +0800 Subject: [PATCH] v0.0.4: add sessions and refine tui --- .gitignore | 1 + cmd/agentu/main.go | 55 +++++- internal/agent/agent.go | 12 ++ internal/session/manager.go | 173 +++++++++++++++++- internal/session/manager_test.go | 180 ++++++++++++++++++- internal/session/store.go | 173 ++++++++++++++++++ internal/session/store_test.go | 170 ++++++++++++++++++ internal/tui/app.go | 291 ++++++++++++++++++++++++++++--- internal/tui/app_test.go | 170 ++++++++++++++++-- internal/tui/markdown.go | 94 +++++++++- internal/tui/markdown_test.go | 20 +++ internal/tui/theme.go | 104 +++++------ 12 files changed, 1333 insertions(+), 110 deletions(-) create mode 100644 internal/session/store.go create mode 100644 internal/session/store_test.go diff --git a/.gitignore b/.gitignore index 3a21f18..651dfdb 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ agentu*.yaml /agentu /coverage.out *.swp +chat-history.txt diff --git a/cmd/agentu/main.go b/cmd/agentu/main.go index 62fe710..56e1034 100644 --- a/cmd/agentu/main.go +++ b/cmd/agentu/main.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "os/signal" + "path/filepath" "strings" "agentu/internal/agent" @@ -31,6 +32,7 @@ func run() error { yolo := flag.Bool("yolo", false, "also enable automatic file writes and shell execution") plain := flag.Bool("plain", false, "use the simple line-based REPL instead of the TUI") themeName := flag.String("theme", "light", "TUI theme: light or dark") + resumeID := flag.String("resume", "", "resume a specific session by ID") flag.Parse() themeMode, err := tui.ParseThemeMode(*themeName) @@ -75,27 +77,52 @@ func run() error { ToolRegistry: registry, ToolTimeout: cfg.Agent.ToolTimeout, }) - modelManager, err := session.NewManager(cfg, assistant, http.DefaultClient) + + // Initialize session store + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("resolve home dir: %w", err) + } + sessionsDir := filepath.Join(home, ".agentu", "sessions") + store, err := session.NewStore(sessionsDir) + if err != nil { + return fmt.Errorf("init session store: %w", err) + } + + modelManager, err := session.NewManager(cfg, assistant, http.DefaultClient, store) if err != nil { return err } + // Resume session + if *resumeID != "" { + if err := modelManager.Resume(*resumeID); err != nil { + return fmt.Errorf("resume session: %w", err) + } + } else { + if err := modelManager.ResumeLatest(); err != nil { + return fmt.Errorf("resume latest session: %w", err) + } + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() if !*plain { - return tui.Run(ctx, assistant, tui.Options{ + err := tui.Run(ctx, assistant, tui.Options{ ModelName: providerConfig.Model, Yolo: *yolo, ThemeMode: themeMode, ModelManager: modelManager, }) + printExitSession(modelManager) + return err } - return repl(ctx, assistant) + return repl(ctx, assistant, modelManager) } -func repl(ctx context.Context, assistant *agent.Agent) error { +func repl(ctx context.Context, assistant *agent.Agent, modelManager *session.Manager) error { scanner := bufio.NewScanner(os.Stdin) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) @@ -108,6 +135,7 @@ func repl(ctx context.Context, assistant *agent.Agent) error { return err } fmt.Println() + printExitSession(modelManager) return nil } @@ -116,6 +144,7 @@ func repl(ctx context.Context, assistant *agent.Agent) error { case "": continue case "/exit", "/quit": + printExitSession(modelManager) return nil case "/clear": assistant.Clear() @@ -126,6 +155,24 @@ func repl(ctx context.Context, assistant *agent.Agent) error { if err := assistant.RunTurn(ctx, input, os.Stdout, os.Stderr); err != nil { fmt.Fprintln(os.Stderr, "\nerror:", err) } + _ = modelManager.Save() fmt.Println() } } + +func printExitSession(m *session.Manager) { + if m == nil { + return + } + _ = m.Save() + id := m.CurrentSessionID() + name := m.CurrentSessionName() + if id != "" { + if name != "" && name != id { + fmt.Fprintf(os.Stderr, "session: %s (%s)\n", id, name) + } else { + fmt.Fprintf(os.Stderr, "session: %s\n", id) + } + fmt.Fprintf(os.Stderr, "resume: agentu --resume %s\n", id) + } +} diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 65089c7..6853499 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -117,6 +117,18 @@ func (a *Agent) Clear() { } } +// Messages returns a copy of the current conversation messages. +func (a *Agent) Messages() []llm.Message { + out := make([]llm.Message, len(a.messages)) + copy(out, a.messages) + return out +} + +// SetMessages replaces the conversation messages (used for session restore). +func (a *Agent) SetMessages(messages []llm.Message) { + a.messages = append([]llm.Message(nil), messages...) +} + func (a *Agent) RunTurn(ctx context.Context, input string, out io.Writer, logs io.Writer) error { input = strings.TrimSpace(input) if input == "" { diff --git a/internal/session/manager.go b/internal/session/manager.go index afbfe9c..1c61ea0 100644 --- a/internal/session/manager.go +++ b/internal/session/manager.go @@ -5,6 +5,7 @@ import ( "net/http" "sort" "strings" + "time" "agentu/internal/agent" "agentu/internal/config" @@ -12,14 +13,16 @@ import ( ) type Manager struct { - cfg *config.Config - agent *agent.Agent - httpClient *http.Client - providerName string - provider config.ProviderConfig + cfg *config.Config + agent *agent.Agent + httpClient *http.Client + providerName string + provider config.ProviderConfig + store *Store + currentSession *Session } -func NewManager(cfg *config.Config, assistant *agent.Agent, httpClient *http.Client) (*Manager, error) { +func NewManager(cfg *config.Config, assistant *agent.Agent, httpClient *http.Client, store *Store) (*Manager, error) { if httpClient == nil { httpClient = http.DefaultClient } @@ -28,13 +31,160 @@ func NewManager(cfg *config.Config, assistant *agent.Agent, httpClient *http.Cli if !ok { return nil, fmt.Errorf("no provider is configured") } - return &Manager{ + m := &Manager{ cfg: cfg, agent: assistant, httpClient: httpClient, providerName: providerName, provider: provider, - }, nil + store: store, + } + return m, nil +} + +// ResumeLatest loads the most recent session, or creates a new one. +func (m *Manager) ResumeLatest() error { + if m.store == nil { + return nil + } + metas, err := m.store.List() + if err != nil { + return err + } + if len(metas) > 0 { + return m.Resume(metas[0].ID) + } + m.newSession() + return nil +} + +// Resume loads a session by ID and restores it into the agent. +func (m *Manager) Resume(id string) error { + if m.store == nil { + return fmt.Errorf("session storage is not configured") + } + sess, err := m.store.Load(id) + if err != nil { + return err + } + m.currentSession = sess + m.agent.SetMessages(sess.Messages) + return nil +} + +// Save persists the current session to disk. +func (m *Manager) Save() error { + if m.store == nil || m.currentSession == nil { + return nil + } + m.currentSession.Messages = m.agent.Messages() + m.currentSession.Provider = m.providerName + m.currentSession.Model = m.provider.Model + // Auto-name if empty + if m.currentSession.Name == "" { + m.currentSession.Name = DefaultName(m.currentSession.Messages) + } + return m.store.Save(m.currentSession) +} + +// Rename changes the current session name and saves. +func (m *Manager) Rename(name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" { + return "", fmt.Errorf("name is required") + } + if m.currentSession == nil { + return "", fmt.Errorf("no active session") + } + m.currentSession.Name = name + if err := m.Save(); err != nil { + return "", err + } + return fmt.Sprintf("Renamed session to %q.", name), nil +} + +// Sessions returns a formatted list of all sessions. +func (m *Manager) Sessions() (string, error) { + if m.store == nil { + return "", fmt.Errorf("session storage is not configured") + } + metas, err := m.store.List() + if err != nil { + return "", err + } + if len(metas) == 0 { + return "No saved sessions.", nil + } + var b strings.Builder + b.WriteString("Sessions:\n") + for _, meta := range metas { + name := meta.Name + if name == "" { + name = "(unnamed)" + } + current := "" + if m.currentSession != nil && meta.ID == m.currentSession.ID { + current = " (current)" + } + ts := meta.UpdatedAt.Local().Format("2006-01-02 15:04") + fmt.Fprintf(&b, " %s %-30s %s %d msgs%s\n", meta.ID, fitName(name, 30), ts, meta.MessageCount, current) + } + return b.String(), nil +} + +// NewSession starts a fresh session, saving the current one first. +func (m *Manager) NewSession() (string, error) { + // Save current if it has user messages + if m.currentSession != nil { + msgs := m.agent.Messages() + hasUser := false + for _, msg := range msgs { + if msg.Role == llm.RoleUser { + hasUser = true + break + } + } + if hasUser { + m.currentSession.Messages = msgs + m.currentSession.Provider = m.providerName + m.currentSession.Model = m.provider.Model + if m.currentSession.Name == "" { + m.currentSession.Name = DefaultName(msgs) + } + if m.store != nil { + _ = m.store.Save(m.currentSession) + } + } + } + m.newSession() + return "Session saved. Starting new session.", nil +} + +func (m *Manager) newSession() { + m.currentSession = &Session{ + ID: GenerateID(), + CreatedAt: time.Now().UTC(), + Provider: m.providerName, + Model: m.provider.Model, + } + m.agent.Clear() +} + +func (m *Manager) CurrentSessionID() string { + if m.currentSession == nil { + return "" + } + return m.currentSession.ID +} + +func (m *Manager) CurrentSessionName() string { + if m.currentSession == nil { + return "" + } + if m.currentSession.Name != "" { + return m.currentSession.Name + } + return m.currentSession.ID } func (m *Manager) CurrentProvider() string { @@ -140,3 +290,10 @@ func contains(values []string, value string) bool { } return false } + +func fitName(name string, width int) string { + if len(name) <= width { + return name + } + return name[:width-3] + "..." +} diff --git a/internal/session/manager_test.go b/internal/session/manager_test.go index 7f6f0c5..2bc230a 100644 --- a/internal/session/manager_test.go +++ b/internal/session/manager_test.go @@ -2,6 +2,7 @@ package session import ( "context" + "path/filepath" "strings" "testing" @@ -19,8 +20,8 @@ func (p *recordingProvider) ChatStream(ctx context.Context, req llm.ChatRequest, return emit(llm.StreamEvent{Content: "ok"}) } -func TestManagerSwitchesModelAndThinking(t *testing.T) { - cfg := &config.Config{ +func testConfig() *config.Config { + return &config.Config{ Providers: map[string]config.ProviderConfig{ "one": { Name: "one", @@ -34,6 +35,11 @@ func TestManagerSwitchesModelAndThinking(t *testing.T) { }, }, } +} + +func testManager(t *testing.T) (*Manager, *agent.Agent, *recordingProvider) { + t.Helper() + cfg := testConfig() provider := &recordingProvider{} assistant := agent.New(agent.Options{ Provider: provider, @@ -43,10 +49,19 @@ func TestManagerSwitchesModelAndThinking(t *testing.T) { ThinkingParam: "thinking", ThinkingEnabled: true, }) - manager, err := NewManager(cfg, assistant, nil) + store, err := NewStore(filepath.Join(t.TempDir(), "sessions")) if err != nil { t.Fatal(err) } + manager, err := NewManager(cfg, assistant, nil, store) + if err != nil { + t.Fatal(err) + } + return manager, assistant, provider +} + +func TestManagerSwitchesModelAndThinking(t *testing.T) { + manager, assistant, provider := testManager(t) if _, err := manager.SetModel("model-b"); err != nil { t.Fatal(err) @@ -80,7 +95,8 @@ func TestManagerRejectsUnknownModelAndThinking(t *testing.T) { }, } assistant := agent.New(agent.Options{Provider: &recordingProvider{}, Model: "model-a"}) - manager, err := NewManager(cfg, assistant, nil) + store, _ := NewStore(filepath.Join(t.TempDir(), "sessions")) + manager, err := NewManager(cfg, assistant, nil, store) if err != nil { t.Fatal(err) } @@ -92,3 +108,159 @@ func TestManagerRejectsUnknownModelAndThinking(t *testing.T) { t.Fatal("expected invalid thinking error") } } + +func TestManagerSaveAndResume(t *testing.T) { + manager, assistant, _ := testManager(t) + + // Start new session and have a conversation + manager.newSession() + var out strings.Builder + assistant.RunTurn(context.Background(), "hello world", &out, nil) + + // Save + if err := manager.Save(); err != nil { + t.Fatalf("save: %v", err) + } + savedID := manager.CurrentSessionID() + if savedID == "" { + t.Fatal("expected session ID after save") + } + // Name should be auto-derived from first user message + if manager.CurrentSessionName() != "hello world" { + t.Fatalf("session name = %q, want %q", manager.CurrentSessionName(), "hello world") + } + + // Start a new session + manager.newSession() + if manager.CurrentSessionID() == savedID { + t.Fatal("new session should have different ID") + } + + // Resume + if err := manager.Resume(savedID); err != nil { + t.Fatalf("resume: %v", err) + } + if manager.CurrentSessionID() != savedID { + t.Fatalf("after resume, ID = %q, want %q", manager.CurrentSessionID(), savedID) + } + msgs := assistant.Messages() + hasUser := false + for _, m := range msgs { + if m.Role == llm.RoleUser && m.Content == "hello world" { + hasUser = true + } + } + if !hasUser { + t.Fatal("resumed session should contain original user message") + } +} + +func TestManagerRename(t *testing.T) { + manager, _, _ := testManager(t) + manager.newSession() + + msg, err := manager.Rename("my cool session") + if err != nil { + t.Fatalf("rename: %v", err) + } + if !strings.Contains(msg, "my cool session") { + t.Fatalf("rename message = %q", msg) + } + if manager.CurrentSessionName() != "my cool session" { + t.Fatalf("name = %q", manager.CurrentSessionName()) + } + + // Verify persisted + _, err = manager.store.Load(manager.CurrentSessionID()) + if err != nil { + t.Fatalf("load after rename: %v", err) + } +} + +func TestManagerRenameEmpty(t *testing.T) { + manager, _, _ := testManager(t) + manager.newSession() + _, err := manager.Rename(" ") + if err == nil { + t.Fatal("expected error for empty name") + } +} + +func TestManagerSessions(t *testing.T) { + manager, _, _ := testManager(t) + + // No sessions yet + out, err := manager.Sessions() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "No saved sessions") { + t.Fatalf("expected 'no saved sessions', got: %s", out) + } + + // Create and save a session + manager.newSession() + manager.Save() + + out, err = manager.Sessions() + if err != nil { + t.Fatal(err) + } + if strings.Contains(out, "No saved sessions") { + t.Fatal("expected session list") + } +} + +func TestManagerNewSession(t *testing.T) { + manager, assistant, _ := testManager(t) + manager.newSession() + var out strings.Builder + assistant.RunTurn(context.Background(), "first message", &out, nil) + + msg, err := manager.NewSession() + if err != nil { + t.Fatalf("new session: %v", err) + } + if !strings.Contains(msg, "Starting new session") { + t.Fatalf("message = %q", msg) + } + // Old session should be saved + metas, _ := manager.store.List() + if len(metas) != 1 { + t.Fatalf("expected 1 saved session, got %d", len(metas)) + } +} + +func TestManagerResumeLatest(t *testing.T) { + manager, assistant, _ := testManager(t) + + // Create two sessions + manager.newSession() + var out strings.Builder + assistant.RunTurn(context.Background(), "session one", &out, nil) + manager.Save() + id1 := manager.CurrentSessionID() + + manager.NewSession() + assistant.RunTurn(context.Background(), "session two", &out, nil) + manager.Save() + id2 := manager.CurrentSessionID() + + // Clear and resume latest + manager.newSession() + if err := manager.ResumeLatest(); err != nil { + t.Fatalf("resume latest: %v", err) + } + // Should resume the most recently updated (id2) + if manager.CurrentSessionID() != id2 { + t.Fatalf("expected to resume %s, got %s", id2, manager.CurrentSessionID()) + } + + // Also verify id1 is resumable + if err := manager.Resume(id1); err != nil { + t.Fatalf("resume id1: %v", err) + } + if manager.CurrentSessionID() != id1 { + t.Fatalf("expected %s, got %s", id1, manager.CurrentSessionID()) + } +} diff --git a/internal/session/store.go b/internal/session/store.go new file mode 100644 index 0000000..93a5086 --- /dev/null +++ b/internal/session/store.go @@ -0,0 +1,173 @@ +package session + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "agentu/internal/llm" +) + +// Session represents a persisted conversation. +type Session struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Provider string `json:"provider"` + Model string `json:"model"` + Messages []llm.Message `json:"messages"` +} + +// SessionMeta is a lightweight summary for listing. +type SessionMeta struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Provider string `json:"provider"` + Model string `json:"model"` + MessageCount int `json:"message_count"` +} + +// Store manages session files under a directory. +type Store struct { + dir string +} + +// NewStore creates a store, ensuring the directory exists. +func NewStore(dir string) (*Store, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("create session dir: %w", err) + } + return &Store{dir: dir}, nil +} + +// Save writes a session to disk. +func (s *Store) Save(sess *Session) error { + if sess.ID == "" { + return fmt.Errorf("session ID is required") + } + sess.UpdatedAt = time.Now().UTC() + if sess.CreatedAt.IsZero() { + sess.CreatedAt = sess.UpdatedAt + } + data, err := json.MarshalIndent(sess, "", " ") + if err != nil { + return fmt.Errorf("marshal session: %w", err) + } + path := s.filePath(sess.ID) + if err := os.WriteFile(path, data, 0o644); err != nil { + return fmt.Errorf("write session: %w", err) + } + return nil +} + +// Load reads a session by ID. +func (s *Store) Load(id string) (*Session, error) { + path := s.filePath(id) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("session %q not found", id) + } + return nil, fmt.Errorf("read session: %w", err) + } + var sess Session + if err := json.Unmarshal(data, &sess); err != nil { + return nil, fmt.Errorf("parse session: %w", err) + } + return &sess, nil +} + +// List returns metadata for all sessions, sorted by updated_at descending. +func (s *Store) List() ([]SessionMeta, error) { + entries, err := os.ReadDir(s.dir) + if err != nil { + return nil, fmt.Errorf("read session dir: %w", err) + } + var metas []SessionMeta + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + path := filepath.Join(s.dir, entry.Name()) + data, err := os.ReadFile(path) + if err != nil { + continue + } + var sess Session + if err := json.Unmarshal(data, &sess); err != nil { + continue + } + metas = append(metas, SessionMeta{ + ID: sess.ID, + Name: sess.Name, + CreatedAt: sess.CreatedAt, + UpdatedAt: sess.UpdatedAt, + Provider: sess.Provider, + Model: sess.Model, + MessageCount: len(sess.Messages), + }) + } + sort.Slice(metas, func(i, j int) bool { + return metas[i].UpdatedAt.After(metas[j].UpdatedAt) + }) + return metas, nil +} + +// Delete removes a session file. +func (s *Store) Delete(id string) error { + path := s.filePath(id) + if err := os.Remove(path); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("session %q not found", id) + } + return fmt.Errorf("delete session: %w", err) + } + return nil +} + +func (s *Store) filePath(id string) string { + return filepath.Join(s.dir, id+".json") +} + +// GenerateID creates a random 8-character hex session ID. +func GenerateID() string { + b := make([]byte, 4) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// DefaultName derives a session name from the first user message. +func DefaultName(messages []llm.Message) string { + for _, msg := range messages { + if msg.Role != llm.RoleUser { + continue + } + text := strings.TrimSpace(msg.Content) + if text == "" { + continue + } + // Take first line, truncate at 40 chars at word boundary + if idx := strings.IndexByte(text, '\n'); idx >= 0 { + text = text[:idx] + } + text = strings.TrimSpace(text) + if len(text) > 40 { + text = text[:40] + if idx := strings.LastIndexByte(text, ' '); idx > 20 { + text = text[:idx] + } + text += "..." + } + return text + } + return "" +} diff --git a/internal/session/store_test.go b/internal/session/store_test.go new file mode 100644 index 0000000..071d443 --- /dev/null +++ b/internal/session/store_test.go @@ -0,0 +1,170 @@ +package session + +import ( + "os" + "path/filepath" + "testing" + + "agentu/internal/llm" +) + +func tempStore(t *testing.T) *Store { + t.Helper() + dir := t.TempDir() + store, err := NewStore(dir) + if err != nil { + t.Fatal(err) + } + return store +} + +func TestSaveAndLoad(t *testing.T) { + store := tempStore(t) + sess := &Session{ + ID: GenerateID(), + Name: "test session", + Provider: "loveuer", + Model: "deepseek-v4-flash", + Messages: []llm.Message{ + {Role: llm.RoleSystem, Content: "you are helpful"}, + {Role: llm.RoleUser, Content: "hello"}, + {Role: llm.RoleAssistant, Content: "hi there"}, + }, + } + if err := store.Save(sess); err != nil { + t.Fatalf("save: %v", err) + } + + loaded, err := store.Load(sess.ID) + if err != nil { + t.Fatalf("load: %v", err) + } + if loaded.ID != sess.ID { + t.Errorf("ID = %q, want %q", loaded.ID, sess.ID) + } + if loaded.Name != sess.Name { + t.Errorf("Name = %q, want %q", loaded.Name, sess.Name) + } + if len(loaded.Messages) != 3 { + t.Errorf("Messages len = %d, want 3", len(loaded.Messages)) + } + if loaded.Messages[1].Content != "hello" { + t.Errorf("Messages[1].Content = %q, want %q", loaded.Messages[1].Content, "hello") + } + if loaded.UpdatedAt.IsZero() { + t.Error("UpdatedAt should be set") + } +} + +func TestLoadNotFound(t *testing.T) { + store := tempStore(t) + _, err := store.Load("nonexistent") + if err == nil { + t.Fatal("expected error for nonexistent session") + } +} + +func TestList(t *testing.T) { + store := tempStore(t) + // Empty list + metas, err := store.List() + if err != nil { + t.Fatalf("list: %v", err) + } + if len(metas) != 0 { + t.Errorf("expected empty list, got %d", len(metas)) + } + + // Save two sessions + store.Save(&Session{ID: "aaa", Name: "first", Messages: []llm.Message{{Role: "user", Content: "a"}}}) + store.Save(&Session{ID: "bbb", Name: "second", Messages: []llm.Message{{Role: "user", Content: "b"}, {Role: "assistant", Content: "c"}}}) + + metas, err = store.List() + if err != nil { + t.Fatalf("list: %v", err) + } + if len(metas) != 2 { + t.Fatalf("expected 2 sessions, got %d", len(metas)) + } + // Sorted by updated_at desc + if metas[0].ID != "bbb" && metas[0].ID != "aaa" { + t.Errorf("unexpected first session ID: %q", metas[0].ID) + } +} + +func TestDelete(t *testing.T) { + store := tempStore(t) + store.Save(&Session{ID: "todelete", Name: "temp"}) + + if err := store.Delete("todelete"); err != nil { + t.Fatalf("delete: %v", err) + } + _, err := store.Load("todelete") + if err == nil { + t.Fatal("expected error after delete") + } +} + +func TestDeleteNotFound(t *testing.T) { + store := tempStore(t) + err := store.Delete("nonexistent") + if err == nil { + t.Fatal("expected error for nonexistent delete") + } +} + +func TestSaveRequiresID(t *testing.T) { + store := tempStore(t) + err := store.Save(&Session{Name: "no id"}) + if err == nil { + t.Fatal("expected error for empty ID") + } +} + +func TestGenerateID(t *testing.T) { + id := GenerateID() + if len(id) != 8 { + t.Errorf("ID length = %d, want 8", len(id)) + } + id2 := GenerateID() + if id == id2 { + t.Error("expected unique IDs") + } +} + +func TestDefaultName(t *testing.T) { + tests := []struct { + name string + messages []llm.Message + want string + }{ + {"empty", nil, ""}, + {"no user msg", []llm.Message{{Role: "system", Content: "hi"}}, ""}, + {"short", []llm.Message{{Role: "user", Content: "hello world"}}, "hello world"}, + {"long", []llm.Message{{Role: "user", Content: "this is a really long message that should be truncated at some point in the middle"}}, "this is a really long message that..."}, + {"multiline", []llm.Message{{Role: "user", Content: "first line\nsecond line"}}, "first line"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DefaultName(tt.messages) + if got != tt.want { + t.Errorf("DefaultName = %q, want %q", got, tt.want) + } + }) + } +} + +func TestNewStoreCreatesDir(t *testing.T) { + dir := filepath.Join(t.TempDir(), "nested", "sessions") + store, err := NewStore(dir) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + info, err := os.Stat(store.dir) + if err != nil { + t.Fatalf("stat: %v", err) + } + if !info.IsDir() { + t.Error("expected directory") + } +} diff --git a/internal/tui/app.go b/internal/tui/app.go index 14580ad..71e91d5 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -9,6 +9,7 @@ import ( "strings" "agentu/internal/agent" + "agentu/internal/llm" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textarea" @@ -33,10 +34,17 @@ type ModelManager interface { CurrentProvider() string CurrentModel() string CurrentThinking() string + CurrentSessionID() string + CurrentSessionName() string Info() string SetProvider(name string) (string, error) SetModel(model string) (string, error) SetThinking(level string) (string, error) + Save() error + Resume(id string) error + Rename(name string) (string, error) + Sessions() (string, error) + NewSession() (string, error) } func Run(ctx context.Context, assistant *agent.Agent, opts Options) error { @@ -81,6 +89,18 @@ type model struct { running bool cancel context.CancelFunc events <-chan tea.Msg + + // Session picker state + picking bool + pickerItems []pickerItem + pickerCursor int +} + +type pickerItem struct { + id string + name string + detail string + isCurrent bool } type assistantChunkMsg string @@ -90,6 +110,8 @@ type turnDoneMsg struct { err error } +var errOpenPicker = fmt.Errorf("open picker") + type slashCommand struct { Name string Description string @@ -100,6 +122,10 @@ var slashCommands = []slashCommand{ {Name: "/exit", Description: "quit"}, {Name: "/quit", Description: "quit"}, {Name: "/model", Description: "model/provider/thinking"}, + {Name: "/sessions", Description: "list sessions"}, + {Name: "/resume", Description: "resume session"}, + {Name: "/rename", Description: "rename session"}, + {Name: "/new", Description: "new session"}, } func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model { @@ -144,9 +170,35 @@ func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model { } func (m model) Init() tea.Cmd { + m.syncMessages() return textarea.Blink } +// syncMessages rebuilds the TUI display messages from the agent's conversation history. +func (m *model) syncMessages() { + if m.agent == nil { + return + } + msgs := m.agent.Messages() + m.messages = m.messages[:0] + for _, msg := range msgs { + switch msg.Role { + case llm.RoleUser: + m.messages = append(m.messages, message{role: roleUser, content: msg.Content}) + case llm.RoleAssistant: + if msg.Content != "" { + m.messages = append(m.messages, message{role: roleAssistant, content: msg.Content}) + } + case llm.RoleTool: + text := cleanToolLog(msg.Content) + if text != "" { + m.messages = append(m.messages, message{role: roleTool, content: text}) + } + } + } + m.refreshViewport(true) +} + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd @@ -167,6 +219,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd case tea.KeyMsg: + if m.picking { + return m.updatePicker(msg) + } switch msg.String() { case "ctrl+c": if m.cancel != nil { @@ -213,6 +268,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.status = "ready" } + // Auto-save session after turn completes + if m.models != nil { + _ = m.models.Save() + } m.resize() m.refreshViewport(true) cmds = append(cmds, textarea.Blink) @@ -252,7 +311,14 @@ func (m model) View() string { return "agentu" } - parts := []string{m.headerView(), m.viewport.View()} + if m.picking { + parts := []string{m.viewport.View()} + parts = append(parts, m.pickerView(), m.modelMetaView(), m.footerView()) + body := lipgloss.JoinVertical(lipgloss.Left, parts...) + return m.styles.App.Width(m.width).Height(m.height).Render(body) + } + + parts := []string{m.viewport.View()} if activity := m.activityView(); activity != "" { parts = append(parts, activity) } @@ -354,11 +420,10 @@ func (m *model) appendAssistantChunk(chunk string) { func (m *model) resize() { width := max(40, m.width) - inputWidth := max(20, width-4) + inputWidth := max(20, width-m.styles.InputBox.GetHorizontalFrameSize()) m.input.SetWidth(inputWidth) m.syncInputHeight() - headerHeight := 1 activityHeight := 0 if m.running { activityHeight = 1 @@ -366,7 +431,7 @@ func (m *model) resize() { inputHeight := m.inputBlockHeight() footerHeight := 2 m.viewport.Width = width - m.viewport.Height = max(4, m.height-headerHeight-activityHeight-inputHeight-footerHeight) + m.viewport.Height = max(4, m.height-activityHeight-inputHeight-footerHeight) m.viewport.Style = m.styles.Viewport } @@ -379,7 +444,7 @@ func (m *model) refreshViewport(bottom bool) { func (m model) renderMessages() string { width := max(40, m.width) - contentWidth := max(20, width-6) + contentWidth := width parts := make([]string, 0, len(m.messages)) for _, msg := range m.messages { content := strings.TrimRight(msg.content, "\n") @@ -397,12 +462,11 @@ func (m model) renderMessages() string { func (m model) messageView(r role, content string, width int) string { label := roleLabel(r) style := m.styles.AssistantMsg - labelStyle := m.styles.AssistantLabel + labelStyle := m.styles.SystemLabel switch r { case roleUser: style = m.styles.UserMessage - labelStyle = m.styles.UserLabel case roleTool: style = m.styles.ToolMessage labelStyle = m.styles.ToolLabel @@ -420,17 +484,33 @@ func (m model) messageView(r role, content string, width int) string { bodyContent = renderMarkdown(content, bodyWidth, m.theme) } + bodyWidth := messageBodyWidth(bodyContent, width, style) + if r == roleUser { + bodyWidth = width + } + body := style.Width(bodyWidth).Render(bodyContent) + if label == "" { + return body + } labelLine := labelStyle.Render(label) - body := style.Width(width).Render(bodyContent) return lipgloss.JoinVertical(lipgloss.Left, labelLine, body) } +func messageBodyWidth(content string, maxWidth int, style lipgloss.Style) int { + maxWidth = max(1, maxWidth) + contentWidth := 1 + for _, line := range strings.Split(content, "\n") { + contentWidth = max(contentWidth, lipgloss.Width(line)) + } + return min(maxWidth, contentWidth+style.GetHorizontalFrameSize()) +} + func roleLabel(r role) string { switch r { case roleUser: - return "You" + return "" case roleAssistant: - return "Agentu" + return "" case roleTool: return "Tool" case roleError: @@ -442,13 +522,6 @@ func roleLabel(r role) string { } } -func (m model) headerView() string { - title := m.styles.Title.Render("agentu") - meta := m.styles.Muted.Render("local agent") - line := lipgloss.JoinHorizontal(lipgloss.Center, title, " ", meta) - return m.styles.Header.Width(max(1, m.width)).Render(line) -} - func (m model) modelMetaView() string { mode := "read-only tools" if m.yolo { @@ -457,6 +530,9 @@ func (m model) modelMetaView() string { modelName := m.modelName parts := []string{} if m.models != nil { + if sessionName := m.models.CurrentSessionName(); sessionName != "" { + parts = append(parts, "session "+sessionName) + } if provider := m.models.CurrentProvider(); provider != "" { parts = append(parts, "provider "+provider) } @@ -571,11 +647,25 @@ func (m model) desiredInputHeight() int { func (m model) inputBlockHeight() int { height := m.input.Height() + m.styles.InputBox.GetVerticalFrameSize() if m.showSlashSuggestions() { - height++ + height += m.slashSuggestionCount() } return height } +func (m model) slashSuggestionCount() int { + prefix := strings.TrimSpace(m.input.Value()) + count := 0 + for _, command := range slashCommands { + if strings.HasPrefix(command.Name, prefix) { + count++ + } + } + if count == 0 { + count = 1 // "No matching commands" + } + return count +} + func (m model) showSlashSuggestions() bool { value := strings.TrimSpace(m.input.Value()) return strings.HasPrefix(value, "/") && !strings.Contains(value, " ") @@ -596,7 +686,7 @@ func (m model) slashSuggestionsView() string { if len(parts) == 0 { parts = append(parts, "No matching commands") } - return m.styles.CommandHint.Width(max(20, m.width-2)).Render(strings.Join(parts, " ")) + return m.styles.CommandHint.Width(max(20, m.width-2)).Render(strings.Join(parts, "\n")) } func (m model) modelInfo() string { @@ -635,6 +725,10 @@ func fitLine(text string, width int) string { func (m *model) runLocalCommand(input string) (tea.Model, tea.Cmd) { output, err := m.handleLocalCommand(input) + if err == errOpenPicker { + m.openSessionPicker() + return *m, nil + } role := roleSystem status := "command" if err != nil { @@ -642,10 +736,13 @@ func (m *model) runLocalCommand(input string) (tea.Model, tea.Cmd) { role = roleError status = "command error" } - m.messages = append(m.messages, message{role: role, content: output}) m.input.SetValue("") m.afterInputChanged() m.status = status + // Rebuild display from agent history (matters after /resume and /new) + m.syncMessages() + // Append command output after synced history + m.messages = append(m.messages, message{role: role, content: output}) m.refreshViewport(true) return *m, nil } @@ -658,6 +755,14 @@ func (m model) handleLocalCommand(input string) (string, error) { switch fields[0] { case "/model": return m.handleModelCommand(fields[1:]) + case "/sessions": + return m.handleSessionsCommand() + case "/resume": + return m.handleResumeCommand(fields[1:]) + case "/rename": + return m.handleRenameCommand(fields[1:]) + case "/new": + return m.handleNewCommand() default: return "", fmt.Errorf("unknown command: %s", fields[0]) } @@ -697,4 +802,150 @@ func (m model) handleModelCommand(args []string) (string, error) { } } +func (m model) handleSessionsCommand() (string, error) { + if m.models == nil { + return "", fmt.Errorf("session management is not configured") + } + return m.models.Sessions() +} + +func (m model) handleResumeCommand(args []string) (string, error) { + if m.models == nil { + return "", fmt.Errorf("session management is not configured") + } + if len(args) > 0 { + id := strings.TrimSpace(args[0]) + if err := m.models.Resume(id); err != nil { + return "", err + } + return fmt.Sprintf("Resumed session %q.", m.models.CurrentSessionName()), nil + } + // No args — open interactive picker (handled in runLocalCommand) + return "", errOpenPicker +} + +func (m model) handleRenameCommand(args []string) (string, error) { + if m.models == nil { + return "", fmt.Errorf("session management is not configured") + } + if len(args) == 0 { + return "", fmt.Errorf("usage: /rename ") + } + name := strings.Join(args, " ") + return m.models.Rename(name) +} + +func (m model) handleNewCommand() (string, error) { + if m.models == nil { + return "", fmt.Errorf("session management is not configured") + } + return m.models.NewSession() +} + +func (m *model) openSessionPicker() { + if m.models == nil { + return + } + output, err := m.models.Sessions() + if err != nil { + m.messages = append(m.messages, message{role: roleError, content: err.Error()}) + return + } + // Parse session list into picker items + m.pickerItems = m.pickerItems[:0] + m.pickerCursor = 0 + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" || line == "Sessions:" || strings.HasPrefix(line, "No saved") { + continue + } + // Format: " id name date msgs (current)" + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + id := fields[0] + isCurrent := strings.Contains(line, "(current)") + // Reconstruct display + m.pickerItems = append(m.pickerItems, pickerItem{ + id: id, + name: line, + isCurrent: isCurrent, + }) + } + if len(m.pickerItems) == 0 { + m.messages = append(m.messages, message{role: roleSystem, content: "No saved sessions."}) + m.refreshViewport(true) + return + } + // Skip current session in picker, pre-select next one + for i, item := range m.pickerItems { + if !item.isCurrent { + m.pickerCursor = i + break + } + } + m.picking = true + m.input.SetValue("") + m.input.Blur() + m.refreshViewport(true) +} + +func (m *model) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c", "esc": + m.picking = false + m.input.Focus() + return *m, textarea.Blink + case "up", "k": + if m.pickerCursor > 0 { + m.pickerCursor-- + } + return *m, nil + case "down", "j": + if m.pickerCursor < len(m.pickerItems)-1 { + m.pickerCursor++ + } + return *m, nil + case "enter": + if m.pickerCursor >= 0 && m.pickerCursor < len(m.pickerItems) { + item := m.pickerItems[m.pickerCursor] + m.picking = false + m.input.Focus() + if err := m.models.Resume(item.id); err != nil { + m.messages = append(m.messages, message{role: roleError, content: err.Error()}) + } else { + m.syncMessages() + m.messages = append(m.messages, message{role: roleSystem, content: fmt.Sprintf("Resumed session %q.", m.models.CurrentSessionName())}) + } + m.refreshViewport(true) + } + return *m, textarea.Blink + } + return *m, nil +} + +func (m model) pickerView() string { + if !m.picking || len(m.pickerItems) == 0 { + return "" + } + width := max(40, m.width-4) + var b strings.Builder + b.WriteString(m.styles.Muted.Render("Select a session (↑/↓ navigate, enter select, esc cancel)")) + b.WriteString("\n\n") + for i, item := range m.pickerItems { + line := item.name + if item.isCurrent { + line += " (current)" + } + if i == m.pickerCursor { + b.WriteString(m.styles.UserLabel.Render("▸ " + line)) + } else { + b.WriteString(m.styles.Muted.Render(" " + line)) + } + b.WriteString("\n") + } + return m.styles.InputBox.Width(width).Render(b.String()) +} + var _ io.Writer = eventWriter{} diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index e043e04..99b035f 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -6,18 +6,65 @@ import ( "testing" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) func TestModelRendersChatShell(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) - rendered := next.(model).View() + m = next.(model) + rendered := m.View() - for _, want := range []string{"agentu", "test-model", "yolo tools", "light", "ctrl+j", "alt+enter"} { + for _, want := range []string{"test-model", "yolo tools", "light", "ctrl+j", "alt+enter"} { if !strings.Contains(rendered, want) { t.Fatalf("rendered view missing %q:\n%s", want, rendered) } } + if strings.Contains(rendered, "local agent") { + t.Fatalf("top header should not render:\n%s", rendered) + } + if _, ok := m.styles.App.GetBackground().(lipgloss.NoColor); !ok { + t.Fatalf("app should not paint a global background") + } + if _, ok := m.styles.Viewport.GetBackground().(lipgloss.NoColor); !ok { + t.Fatalf("viewport should not paint an assistant background") + } +} + +func TestInputTextDoesNotPaintBackground(t *testing.T) { + m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) + for name, color := range map[string]any{ + "input box": m.styles.InputBox.GetBackground(), + "focused base": m.input.FocusedStyle.Base.GetBackground(), + "focused cursor line": m.input.FocusedStyle.CursorLine.GetBackground(), + "blurred base": m.input.BlurredStyle.Base.GetBackground(), + "blurred cursor line": m.input.BlurredStyle.CursorLine.GetBackground(), + } { + if _, ok := color.(lipgloss.NoColor); !ok { + t.Fatalf("%s should not paint input text background", name) + } + } +} + +func TestInputBoxIsRoomier(t *testing.T) { + m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + m = next.(model) + + if got := m.styles.InputBox.GetVerticalFrameSize(); got != 2 { + t.Fatalf("input box vertical frame should stay compact, got %d", got) + } + if got := m.styles.InputBox.GetHorizontalFrameSize(); got < 6 { + t.Fatalf("input box horizontal frame should include roomier padding, got %d", got) + } + if got, want := m.input.Width(), 100-m.styles.InputBox.GetHorizontalFrameSize(); got != want { + t.Fatalf("input content width = %d, want %d", got, want) + } + rendered := m.inputView() + line := strings.Split(rendered, "\n")[0] + if got := lipgloss.Width(line); got != 100 { + t.Fatalf("input box rendered width = %d, want 100:\n%s", got, rendered) + } } func TestModelMetaRendersBelowInput(t *testing.T) { @@ -63,36 +110,87 @@ func TestRunningStateRendersActivityIndicator(t *testing.T) { } } -func TestUserMessageRendersOnLeft(t *testing.T) { +func TestUserMessageRendersFullWidthBubble(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) m = next.(model) - rendered := m.messageView(roleUser, "hello", 80) - if strings.HasPrefix(rendered, " ") { - t.Fatalf("user message should not be right-aligned:\n%q", rendered) - } - if !strings.Contains(rendered, "You") || !strings.Contains(rendered, "hello") { + rendered := m.messageView(roleUser, "hi", 80) + if !strings.Contains(rendered, "hi") { t.Fatalf("user message missing content:\n%s", rendered) } + if strings.Contains(rendered, "You") { + t.Fatalf("user message should not render label:\n%s", rendered) + } + if !strings.Contains(rendered, " hi") || !strings.Contains(rendered, "hi ") { + t.Fatalf("user message should have horizontal padding:\n%q", rendered) + } + lines := strings.Split(rendered, "\n") + if len(lines) < 3 { + t.Fatalf("user message background should include vertical padding:\n%q", rendered) + } + for _, line := range lines { + if got := lipgloss.Width(line); got != 80 { + t.Fatalf("user message background should span full width, line width = %d:\n%q", got, rendered) + } + } + m.width = 80 + m.messages = []message{{role: roleUser, content: "hi"}} + rendered = m.renderMessages() + for _, line := range strings.Split(rendered, "\n") { + if got := lipgloss.Width(line); got != 80 { + t.Fatalf("rendered user transcript should span full width, line width = %d:\n%q", got, rendered) + } + } } -func TestAssistantMessagesRenderMarkdown(t *testing.T) { +func TestAssistantMessagesRenderMarkdownWithoutChrome(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) m = next.(model) rendered := m.messageView(roleAssistant, "# Title\n\n- item", 80) - for _, want := range []string{"Agentu", "Title", "item"} { + for _, want := range []string{"Title", "item"} { if !strings.Contains(rendered, want) { t.Fatalf("assistant markdown missing %q:\n%s", want, rendered) } } + for _, unwanted := range []string{"Agentu", "│", "┃", "|"} { + if strings.Contains(rendered, unwanted) { + t.Fatalf("assistant message should not render chrome %q:\n%s", unwanted, rendered) + } + } if strings.Contains(rendered, "# Title") { t.Fatalf("assistant markdown heading was not rendered:\n%s", rendered) } } +func TestAssistantMessageRendersPaddedBodyWithoutBackground(t *testing.T) { + m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + m = next.(model) + if _, ok := m.styles.AssistantMsg.GetBackground().(lipgloss.NoColor); !ok { + t.Fatalf("assistant message should not set a background color") + } + + rendered := m.messageView(roleAssistant, "hello", 80) + if !strings.Contains(rendered, "hello") { + t.Fatalf("assistant message missing content:\n%s", rendered) + } + lines := strings.Split(rendered, "\n") + if len(lines) < 3 { + t.Fatalf("assistant message should include vertical padding:\n%q", rendered) + } + if got := lipgloss.Width(rendered); got <= len("hello") { + t.Fatalf("assistant message should be wider than text, width = %d:\n%q", got, rendered) + } + for _, line := range lines { + if got := lipgloss.Width(line); got >= 80 { + t.Fatalf("assistant message should not fill the row, line width = %d:\n%q", got, rendered) + } + } +} + func TestUserMessagesDoNotRenderMarkdown(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) @@ -106,7 +204,7 @@ func TestUserMessagesDoNotRenderMarkdown(t *testing.T) { } } -func TestMessageRoleLabels(t *testing.T) { +func TestMessageRoleLabelsDoNotLabelUserOrAssistant(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) m = next.(model) @@ -116,9 +214,14 @@ func TestMessageRoleLabels(t *testing.T) { } rendered := m.renderMessages() - for _, want := range []string{"You", "Agentu"} { + for _, want := range []string{"hello", "hi"} { if !strings.Contains(rendered, want) { - t.Fatalf("messages missing %q:\n%s", want, rendered) + t.Fatalf("messages missing content %q:\n%s", want, rendered) + } + } + for _, unwanted := range []string{"You", "Agentu"} { + if strings.Contains(rendered, unwanted) { + t.Fatalf("message should not render label %q:\n%s", unwanted, rendered) } } } @@ -197,9 +300,11 @@ func TestModelThinkingCommand(t *testing.T) { } type fakeModelManager struct { - provider string - model string - thinking string + provider string + model string + thinking string + sessionName string + saved bool } func (f *fakeModelManager) CurrentProvider() string { @@ -217,6 +322,17 @@ func (f *fakeModelManager) CurrentThinking() string { return f.thinking } +func (f *fakeModelManager) CurrentSessionID() string { + return "test-id" +} + +func (f *fakeModelManager) CurrentSessionName() string { + if f.sessionName == "" { + return "test-session" + } + return f.sessionName +} + func (f *fakeModelManager) Info() string { return "provider: " + f.CurrentProvider() + "\nmodel: " + f.model + "\nthinking: " + f.thinking } @@ -236,6 +352,28 @@ func (f *fakeModelManager) SetThinking(level string) (string, error) { return f.Info(), nil } +func (f *fakeModelManager) Save() error { + f.saved = true + return nil +} + +func (f *fakeModelManager) Resume(id string) error { + return nil +} + +func (f *fakeModelManager) Rename(name string) (string, error) { + f.sessionName = name + return "Renamed.", nil +} + +func (f *fakeModelManager) Sessions() (string, error) { + return "No sessions.", nil +} + +func (f *fakeModelManager) NewSession() (string, error) { + return "New session.", nil +} + func TestParseThemeMode(t *testing.T) { for input, want := range map[string]ThemeMode{ "": ThemeLight, diff --git a/internal/tui/markdown.go b/internal/tui/markdown.go index f9dfcb1..a25a59f 100644 --- a/internal/tui/markdown.go +++ b/internal/tui/markdown.go @@ -2,6 +2,8 @@ package tui import ( "strings" + "unicode" + "unicode/utf8" "github.com/charmbracelet/glamour" "github.com/charmbracelet/glamour/ansi" @@ -24,7 +26,7 @@ func renderMarkdown(content string, width int, th theme) string { if err != nil { return content } - return strings.TrimRight(rendered, "\n") + return trimRenderedMarkdown(rendered) } func markdownStyle(th theme) ansi.StyleConfig { @@ -32,6 +34,7 @@ func markdownStyle(th theme) ansi.StyleConfig { if th.Mode == ThemeDark { cfg = glamstyles.DarkStyleConfig } + clearMarkdownBackgrounds(&cfg) zero := uint(0) cfg.Document.Margin = &zero @@ -51,7 +54,7 @@ func markdownStyle(th theme) ansi.StyleConfig { cfg.LinkText.Color = stringPtr(th.User) cfg.Code.Color = stringPtr(th.Tool) - cfg.Code.BackgroundColor = stringPtr(th.SurfaceAlt) + cfg.Code.BackgroundColor = nil cfg.CodeBlock.Color = stringPtr(th.Text) cfg.CodeBlock.BackgroundColor = nil cfg.CodeBlock.Margin = &zero @@ -59,6 +62,93 @@ func markdownStyle(th theme) ansi.StyleConfig { return cfg } +func clearMarkdownBackgrounds(cfg *ansi.StyleConfig) { + cfg.Document.BackgroundColor = nil + cfg.BlockQuote.BackgroundColor = nil + cfg.Paragraph.BackgroundColor = nil + cfg.Heading.BackgroundColor = nil + cfg.H1.BackgroundColor = nil + cfg.H2.BackgroundColor = nil + cfg.H3.BackgroundColor = nil + cfg.H4.BackgroundColor = nil + cfg.H5.BackgroundColor = nil + cfg.H6.BackgroundColor = nil + cfg.Text.BackgroundColor = nil + cfg.Strikethrough.BackgroundColor = nil + cfg.Emph.BackgroundColor = nil + cfg.Strong.BackgroundColor = nil + cfg.HorizontalRule.BackgroundColor = nil + cfg.Item.BackgroundColor = nil + cfg.Enumeration.BackgroundColor = nil + cfg.Task.BackgroundColor = nil + cfg.Link.BackgroundColor = nil + cfg.LinkText.BackgroundColor = nil + cfg.Image.BackgroundColor = nil + cfg.ImageText.BackgroundColor = nil + cfg.Code.BackgroundColor = nil + cfg.CodeBlock.BackgroundColor = nil + cfg.Table.BackgroundColor = nil + cfg.DefinitionList.BackgroundColor = nil + cfg.DefinitionTerm.BackgroundColor = nil + cfg.DefinitionDescription.BackgroundColor = nil + cfg.HTMLBlock.BackgroundColor = nil + cfg.HTMLSpan.BackgroundColor = nil +} + +func trimRenderedMarkdown(value string) string { + lines := strings.Split(strings.TrimRight(value, "\n"), "\n") + for i, line := range lines { + lines[i] = trimStyledTrailingSpaces(line) + } + return strings.Join(lines, "\n") +} + +func trimStyledTrailingSpaces(line string) string { + cut := 0 + seenNonSpace := false + seenWhitespaceAfterLastNonSpace := false + for i := 0; i < len(line); { + if end, ok := ansiSequenceEnd(line, i); ok { + if seenNonSpace && !seenWhitespaceAfterLastNonSpace { + cut = end + } + i = end + continue + } + + r, size := utf8.DecodeRuneInString(line[i:]) + if r == utf8.RuneError && size == 0 { + break + } + end := i + size + if unicode.IsSpace(r) { + if seenNonSpace { + seenWhitespaceAfterLastNonSpace = true + } + } else { + seenNonSpace = true + seenWhitespaceAfterLastNonSpace = false + cut = end + } + i = end + } + if cut == 0 { + return "" + } + return line[:cut] +} + +func ansiSequenceEnd(value string, start int) (int, bool) { + if start+2 >= len(value) || value[start] != '\x1b' || value[start+1] != '[' { + return 0, false + } + for i := start + 2; i < len(value); i++ { + if value[i] >= 0x40 && value[i] <= 0x7e { + return i + 1, true + } + } + return 0, false +} func stringPtr(value string) *string { return &value } diff --git a/internal/tui/markdown_test.go b/internal/tui/markdown_test.go index bbe180f..397e9f2 100644 --- a/internal/tui/markdown_test.go +++ b/internal/tui/markdown_test.go @@ -37,6 +37,26 @@ func TestRenderMarkdownSupportsThemesAndNarrowWidth(t *testing.T) { } } +func TestRenderMarkdownDoesNotEmitBackgroundColors(t *testing.T) { + content := "# Title\n\n`inline`\n\n```go\nfmt.Println(\"x\")\n```" + for _, mode := range []ThemeMode{ThemeLight, ThemeDark} { + rendered := renderMarkdown(content, 80, themeForMode(mode)) + if backgroundPattern.MatchString(rendered) { + t.Fatalf("markdown output for %s should not emit background colors:\n%q", mode, rendered) + } + } +} + +func TestRenderMarkdownTrimsGlamourTrailingSpaces(t *testing.T) { + rendered := renderMarkdown("hello", 80, themeForMode(ThemeLight)) + plain := stripANSI(rendered) + if plain != "hello" { + t.Fatalf("markdown should not pad a plain assistant line:\n%q", plain) + } +} + +var backgroundPattern = regexp.MustCompile(`\x1b\[[0-9;:]*48[;:][0-9;:]*m`) + var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;:]*[A-Za-z]`) func stripANSI(value string) string { diff --git a/internal/tui/theme.go b/internal/tui/theme.go index 33f2dc5..fa08c42 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -45,26 +45,23 @@ type theme struct { } type styles struct { - App lipgloss.Style - Header lipgloss.Style - Title lipgloss.Style - Muted lipgloss.Style - Activity lipgloss.Style - ModelMeta lipgloss.Style - Footer lipgloss.Style - Viewport lipgloss.Style - InputBox lipgloss.Style - CommandHint lipgloss.Style - UserLabel lipgloss.Style - AssistantLabel lipgloss.Style - ToolLabel lipgloss.Style - ErrorLabel lipgloss.Style - SystemLabel lipgloss.Style - UserMessage lipgloss.Style - AssistantMsg lipgloss.Style - ToolMessage lipgloss.Style - ErrorMessage lipgloss.Style - SystemMessage lipgloss.Style + App lipgloss.Style + Muted lipgloss.Style + Activity lipgloss.Style + ModelMeta lipgloss.Style + Footer lipgloss.Style + Viewport lipgloss.Style + InputBox lipgloss.Style + CommandHint lipgloss.Style + UserLabel lipgloss.Style + ToolLabel lipgloss.Style + ErrorLabel lipgloss.Style + SystemLabel lipgloss.Style + UserMessage lipgloss.Style + AssistantMsg lipgloss.Style + ToolMessage lipgloss.Style + ErrorMessage lipgloss.Style + SystemMessage lipgloss.Style } func themeForMode(mode ThemeMode) theme { @@ -113,17 +110,7 @@ func darkTheme() theme { func (t theme) styles() styles { return styles{ App: lipgloss.NewStyle(). - Foreground(lipgloss.Color(t.Text)). - Background(lipgloss.Color(t.Background)), - - Header: lipgloss.NewStyle(). - Foreground(lipgloss.Color(t.Text)). - Background(lipgloss.Color(t.SurfaceAlt)). - Padding(0, 1), - - Title: lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color(t.Title)), + Foreground(lipgloss.Color(t.Text)), Muted: lipgloss.NewStyle(). Foreground(lipgloss.Color(t.Muted)), @@ -141,36 +128,32 @@ func (t theme) styles() styles { Footer: lipgloss.NewStyle(). Foreground(lipgloss.Color(t.Muted)). - Background(lipgloss.Color(t.Background)). Padding(0, 1), Viewport: lipgloss.NewStyle(). - Foreground(lipgloss.Color(t.Text)). - Background(lipgloss.Color(t.Background)), + Foreground(lipgloss.Color(t.Text)), InputBox: lipgloss.NewStyle(). Foreground(lipgloss.Color(t.Text)). - Background(lipgloss.Color(t.Surface)). - Border(lipgloss.NormalBorder()). + Border(lipgloss.ThickBorder()). BorderForeground(lipgloss.Color(t.Border)). - Padding(0, 1), + Padding(0, 2), CommandHint: lipgloss.NewStyle(). Foreground(lipgloss.Color(t.Muted)). Background(lipgloss.Color(t.SurfaceAlt)). Padding(0, 1), - UserLabel: labelStyle(t.User), - AssistantLabel: labelStyle(t.Assistant), - ToolLabel: labelStyle(t.Tool), - ErrorLabel: labelStyle(t.Error), - SystemLabel: labelStyle(t.System), + UserLabel: labelStyle(t.User), + ToolLabel: labelStyle(t.Tool), + ErrorLabel: labelStyle(t.Error), + SystemLabel: labelStyle(t.System), - UserMessage: messageStyle(t.Text, t.User), - AssistantMsg: messageStyle(t.Text, t.Assistant), - ToolMessage: messageStyle(t.Text, t.Tool), - ErrorMessage: messageStyle(t.Error, t.Error), - SystemMessage: lipgloss.NewStyle().Foreground(lipgloss.Color(t.System)).PaddingLeft(1), + UserMessage: bubbleMessageStyle(t.Text, t.SurfaceAlt), + AssistantMsg: assistantMessageStyle(t.Text), + ToolMessage: messageStyle(t.Text), + ErrorMessage: messageStyle(t.Error), + SystemMessage: lipgloss.NewStyle().Foreground(lipgloss.Color(t.System)).Padding(0, 1), } } @@ -180,21 +163,30 @@ func labelStyle(color string) lipgloss.Style { Foreground(lipgloss.Color(color)) } -func messageStyle(textColor, accentColor string) lipgloss.Style { +func messageStyle(textColor string) lipgloss.Style { return lipgloss.NewStyle(). Foreground(lipgloss.Color(textColor)). - Border(lipgloss.ThickBorder(), false, false, false, true). - BorderForeground(lipgloss.Color(accentColor)). - PaddingLeft(1) + Padding(0, 1) +} + +func assistantMessageStyle(textColor string) lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color(textColor)). + Padding(1, 2) +} + +func bubbleMessageStyle(textColor, backgroundColor string) lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color(textColor)). + Background(lipgloss.Color(backgroundColor)). + Padding(1, 2) } func applyTextareaTheme(input *textarea.Model, t theme) { focused := textarea.Style{ Base: lipgloss.NewStyle(). - Foreground(lipgloss.Color(t.Text)). - Background(lipgloss.Color(t.Surface)), - CursorLine: lipgloss.NewStyle(). - Background(lipgloss.Color(t.Surface)), + Foreground(lipgloss.Color(t.Text)), + CursorLine: lipgloss.NewStyle(), Placeholder: lipgloss.NewStyle(). Foreground(lipgloss.Color(t.Muted)), Prompt: lipgloss.NewStyle(). @@ -205,7 +197,7 @@ func applyTextareaTheme(input *textarea.Model, t theme) { Foreground(lipgloss.Color(t.Surface)), } blurred := focused - blurred.CursorLine = lipgloss.NewStyle().Background(lipgloss.Color(t.Surface)) + blurred.CursorLine = lipgloss.NewStyle() input.FocusedStyle = focused input.BlurredStyle = blurred