v0.0.4: add sessions and refine tui

This commit is contained in:
loveuer
2026-06-23 17:49:36 +08:00
parent 8dbcb1147f
commit e4c75c7c0e
12 changed files with 1333 additions and 110 deletions
+1
View File
@@ -2,3 +2,4 @@ agentu*.yaml
/agentu /agentu
/coverage.out /coverage.out
*.swp *.swp
chat-history.txt
+51 -4
View File
@@ -9,6 +9,7 @@ import (
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"path/filepath"
"strings" "strings"
"agentu/internal/agent" "agentu/internal/agent"
@@ -31,6 +32,7 @@ func run() error {
yolo := flag.Bool("yolo", false, "also enable automatic file writes and shell execution") 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") 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") themeName := flag.String("theme", "light", "TUI theme: light or dark")
resumeID := flag.String("resume", "", "resume a specific session by ID")
flag.Parse() flag.Parse()
themeMode, err := tui.ParseThemeMode(*themeName) themeMode, err := tui.ParseThemeMode(*themeName)
@@ -75,27 +77,52 @@ func run() error {
ToolRegistry: registry, ToolRegistry: registry,
ToolTimeout: cfg.Agent.ToolTimeout, 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 { if err != nil {
return err 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) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop() defer stop()
if !*plain { if !*plain {
return tui.Run(ctx, assistant, tui.Options{ err := tui.Run(ctx, assistant, tui.Options{
ModelName: providerConfig.Model, ModelName: providerConfig.Model,
Yolo: *yolo, Yolo: *yolo,
ThemeMode: themeMode, ThemeMode: themeMode,
ModelManager: modelManager, 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 := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
@@ -108,6 +135,7 @@ func repl(ctx context.Context, assistant *agent.Agent) error {
return err return err
} }
fmt.Println() fmt.Println()
printExitSession(modelManager)
return nil return nil
} }
@@ -116,6 +144,7 @@ func repl(ctx context.Context, assistant *agent.Agent) error {
case "": case "":
continue continue
case "/exit", "/quit": case "/exit", "/quit":
printExitSession(modelManager)
return nil return nil
case "/clear": case "/clear":
assistant.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 { if err := assistant.RunTurn(ctx, input, os.Stdout, os.Stderr); err != nil {
fmt.Fprintln(os.Stderr, "\nerror:", err) fmt.Fprintln(os.Stderr, "\nerror:", err)
} }
_ = modelManager.Save()
fmt.Println() 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)
}
}
+12
View File
@@ -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 { func (a *Agent) RunTurn(ctx context.Context, input string, out io.Writer, logs io.Writer) error {
input = strings.TrimSpace(input) input = strings.TrimSpace(input)
if input == "" { if input == "" {
+160 -3
View File
@@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"sort" "sort"
"strings" "strings"
"time"
"agentu/internal/agent" "agentu/internal/agent"
"agentu/internal/config" "agentu/internal/config"
@@ -17,9 +18,11 @@ type Manager struct {
httpClient *http.Client httpClient *http.Client
providerName string providerName string
provider config.ProviderConfig 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 { if httpClient == nil {
httpClient = http.DefaultClient httpClient = http.DefaultClient
} }
@@ -28,13 +31,160 @@ func NewManager(cfg *config.Config, assistant *agent.Agent, httpClient *http.Cli
if !ok { if !ok {
return nil, fmt.Errorf("no provider is configured") return nil, fmt.Errorf("no provider is configured")
} }
return &Manager{ m := &Manager{
cfg: cfg, cfg: cfg,
agent: assistant, agent: assistant,
httpClient: httpClient, httpClient: httpClient,
providerName: providerName, providerName: providerName,
provider: provider, 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 { func (m *Manager) CurrentProvider() string {
@@ -140,3 +290,10 @@ func contains(values []string, value string) bool {
} }
return false return false
} }
func fitName(name string, width int) string {
if len(name) <= width {
return name
}
return name[:width-3] + "..."
}
+176 -4
View File
@@ -2,6 +2,7 @@ package session
import ( import (
"context" "context"
"path/filepath"
"strings" "strings"
"testing" "testing"
@@ -19,8 +20,8 @@ func (p *recordingProvider) ChatStream(ctx context.Context, req llm.ChatRequest,
return emit(llm.StreamEvent{Content: "ok"}) return emit(llm.StreamEvent{Content: "ok"})
} }
func TestManagerSwitchesModelAndThinking(t *testing.T) { func testConfig() *config.Config {
cfg := &config.Config{ return &config.Config{
Providers: map[string]config.ProviderConfig{ Providers: map[string]config.ProviderConfig{
"one": { "one": {
Name: "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{} provider := &recordingProvider{}
assistant := agent.New(agent.Options{ assistant := agent.New(agent.Options{
Provider: provider, Provider: provider,
@@ -43,10 +49,19 @@ func TestManagerSwitchesModelAndThinking(t *testing.T) {
ThinkingParam: "thinking", ThinkingParam: "thinking",
ThinkingEnabled: true, ThinkingEnabled: true,
}) })
manager, err := NewManager(cfg, assistant, nil) store, err := NewStore(filepath.Join(t.TempDir(), "sessions"))
if err != nil { if err != nil {
t.Fatal(err) 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 { if _, err := manager.SetModel("model-b"); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -80,7 +95,8 @@ func TestManagerRejectsUnknownModelAndThinking(t *testing.T) {
}, },
} }
assistant := agent.New(agent.Options{Provider: &recordingProvider{}, Model: "model-a"}) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -92,3 +108,159 @@ func TestManagerRejectsUnknownModelAndThinking(t *testing.T) {
t.Fatal("expected invalid thinking error") 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())
}
}
+173
View File
@@ -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 ""
}
+170
View File
@@ -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")
}
}
+271 -20
View File
@@ -9,6 +9,7 @@ import (
"strings" "strings"
"agentu/internal/agent" "agentu/internal/agent"
"agentu/internal/llm"
"github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/textarea"
@@ -33,10 +34,17 @@ type ModelManager interface {
CurrentProvider() string CurrentProvider() string
CurrentModel() string CurrentModel() string
CurrentThinking() string CurrentThinking() string
CurrentSessionID() string
CurrentSessionName() string
Info() string Info() string
SetProvider(name string) (string, error) SetProvider(name string) (string, error)
SetModel(model string) (string, error) SetModel(model string) (string, error)
SetThinking(level 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 { func Run(ctx context.Context, assistant *agent.Agent, opts Options) error {
@@ -81,6 +89,18 @@ type model struct {
running bool running bool
cancel context.CancelFunc cancel context.CancelFunc
events <-chan tea.Msg 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 type assistantChunkMsg string
@@ -90,6 +110,8 @@ type turnDoneMsg struct {
err error err error
} }
var errOpenPicker = fmt.Errorf("open picker")
type slashCommand struct { type slashCommand struct {
Name string Name string
Description string Description string
@@ -100,6 +122,10 @@ var slashCommands = []slashCommand{
{Name: "/exit", Description: "quit"}, {Name: "/exit", Description: "quit"},
{Name: "/quit", Description: "quit"}, {Name: "/quit", Description: "quit"},
{Name: "/model", Description: "model/provider/thinking"}, {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 { 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 { func (m model) Init() tea.Cmd {
m.syncMessages()
return textarea.Blink 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) { func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd var cmds []tea.Cmd
@@ -167,6 +219,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd return m, cmd
case tea.KeyMsg: case tea.KeyMsg:
if m.picking {
return m.updatePicker(msg)
}
switch msg.String() { switch msg.String() {
case "ctrl+c": case "ctrl+c":
if m.cancel != nil { if m.cancel != nil {
@@ -213,6 +268,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else { } else {
m.status = "ready" m.status = "ready"
} }
// Auto-save session after turn completes
if m.models != nil {
_ = m.models.Save()
}
m.resize() m.resize()
m.refreshViewport(true) m.refreshViewport(true)
cmds = append(cmds, textarea.Blink) cmds = append(cmds, textarea.Blink)
@@ -252,7 +311,14 @@ func (m model) View() string {
return "agentu" 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 != "" { if activity := m.activityView(); activity != "" {
parts = append(parts, activity) parts = append(parts, activity)
} }
@@ -354,11 +420,10 @@ func (m *model) appendAssistantChunk(chunk string) {
func (m *model) resize() { func (m *model) resize() {
width := max(40, m.width) width := max(40, m.width)
inputWidth := max(20, width-4) inputWidth := max(20, width-m.styles.InputBox.GetHorizontalFrameSize())
m.input.SetWidth(inputWidth) m.input.SetWidth(inputWidth)
m.syncInputHeight() m.syncInputHeight()
headerHeight := 1
activityHeight := 0 activityHeight := 0
if m.running { if m.running {
activityHeight = 1 activityHeight = 1
@@ -366,7 +431,7 @@ func (m *model) resize() {
inputHeight := m.inputBlockHeight() inputHeight := m.inputBlockHeight()
footerHeight := 2 footerHeight := 2
m.viewport.Width = width 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 m.viewport.Style = m.styles.Viewport
} }
@@ -379,7 +444,7 @@ func (m *model) refreshViewport(bottom bool) {
func (m model) renderMessages() string { func (m model) renderMessages() string {
width := max(40, m.width) width := max(40, m.width)
contentWidth := max(20, width-6) contentWidth := width
parts := make([]string, 0, len(m.messages)) parts := make([]string, 0, len(m.messages))
for _, msg := range m.messages { for _, msg := range m.messages {
content := strings.TrimRight(msg.content, "\n") 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 { func (m model) messageView(r role, content string, width int) string {
label := roleLabel(r) label := roleLabel(r)
style := m.styles.AssistantMsg style := m.styles.AssistantMsg
labelStyle := m.styles.AssistantLabel labelStyle := m.styles.SystemLabel
switch r { switch r {
case roleUser: case roleUser:
style = m.styles.UserMessage style = m.styles.UserMessage
labelStyle = m.styles.UserLabel
case roleTool: case roleTool:
style = m.styles.ToolMessage style = m.styles.ToolMessage
labelStyle = m.styles.ToolLabel 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) 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) labelLine := labelStyle.Render(label)
body := style.Width(width).Render(bodyContent)
return lipgloss.JoinVertical(lipgloss.Left, labelLine, body) 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 { func roleLabel(r role) string {
switch r { switch r {
case roleUser: case roleUser:
return "You" return ""
case roleAssistant: case roleAssistant:
return "Agentu" return ""
case roleTool: case roleTool:
return "Tool" return "Tool"
case roleError: 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 { func (m model) modelMetaView() string {
mode := "read-only tools" mode := "read-only tools"
if m.yolo { if m.yolo {
@@ -457,6 +530,9 @@ func (m model) modelMetaView() string {
modelName := m.modelName modelName := m.modelName
parts := []string{} parts := []string{}
if m.models != nil { if m.models != nil {
if sessionName := m.models.CurrentSessionName(); sessionName != "" {
parts = append(parts, "session "+sessionName)
}
if provider := m.models.CurrentProvider(); provider != "" { if provider := m.models.CurrentProvider(); provider != "" {
parts = append(parts, "provider "+provider) parts = append(parts, "provider "+provider)
} }
@@ -571,11 +647,25 @@ func (m model) desiredInputHeight() int {
func (m model) inputBlockHeight() int { func (m model) inputBlockHeight() int {
height := m.input.Height() + m.styles.InputBox.GetVerticalFrameSize() height := m.input.Height() + m.styles.InputBox.GetVerticalFrameSize()
if m.showSlashSuggestions() { if m.showSlashSuggestions() {
height++ height += m.slashSuggestionCount()
} }
return height 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 { func (m model) showSlashSuggestions() bool {
value := strings.TrimSpace(m.input.Value()) value := strings.TrimSpace(m.input.Value())
return strings.HasPrefix(value, "/") && !strings.Contains(value, " ") return strings.HasPrefix(value, "/") && !strings.Contains(value, " ")
@@ -596,7 +686,7 @@ func (m model) slashSuggestionsView() string {
if len(parts) == 0 { if len(parts) == 0 {
parts = append(parts, "No matching commands") 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 { 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) { func (m *model) runLocalCommand(input string) (tea.Model, tea.Cmd) {
output, err := m.handleLocalCommand(input) output, err := m.handleLocalCommand(input)
if err == errOpenPicker {
m.openSessionPicker()
return *m, nil
}
role := roleSystem role := roleSystem
status := "command" status := "command"
if err != nil { if err != nil {
@@ -642,10 +736,13 @@ func (m *model) runLocalCommand(input string) (tea.Model, tea.Cmd) {
role = roleError role = roleError
status = "command error" status = "command error"
} }
m.messages = append(m.messages, message{role: role, content: output})
m.input.SetValue("") m.input.SetValue("")
m.afterInputChanged() m.afterInputChanged()
m.status = status 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) m.refreshViewport(true)
return *m, nil return *m, nil
} }
@@ -658,6 +755,14 @@ func (m model) handleLocalCommand(input string) (string, error) {
switch fields[0] { switch fields[0] {
case "/model": case "/model":
return m.handleModelCommand(fields[1:]) 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: default:
return "", fmt.Errorf("unknown command: %s", fields[0]) 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>")
}
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{} var _ io.Writer = eventWriter{}
+151 -13
View File
@@ -6,18 +6,65 @@ import (
"testing" "testing"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
) )
func TestModelRendersChatShell(t *testing.T) { func TestModelRendersChatShell(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true}) m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) 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) { if !strings.Contains(rendered, want) {
t.Fatalf("rendered view missing %q:\n%s", want, rendered) 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) { 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"}) m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model) m = next.(model)
rendered := m.messageView(roleUser, "hello", 80) rendered := m.messageView(roleUser, "hi", 80)
if strings.HasPrefix(rendered, " ") { if !strings.Contains(rendered, "hi") {
t.Fatalf("user message should not be right-aligned:\n%q", rendered)
}
if !strings.Contains(rendered, "You") || !strings.Contains(rendered, "hello") {
t.Fatalf("user message missing content:\n%s", rendered) 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"}) m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model) m = next.(model)
rendered := m.messageView(roleAssistant, "# Title\n\n- item", 80) 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) { if !strings.Contains(rendered, want) {
t.Fatalf("assistant markdown missing %q:\n%s", want, rendered) 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") { if strings.Contains(rendered, "# Title") {
t.Fatalf("assistant markdown heading was not rendered:\n%s", rendered) 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) { func TestUserMessagesDoNotRenderMarkdown(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) 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"}) m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model) m = next.(model)
@@ -116,9 +214,14 @@ func TestMessageRoleLabels(t *testing.T) {
} }
rendered := m.renderMessages() rendered := m.renderMessages()
for _, want := range []string{"You", "Agentu"} { for _, want := range []string{"hello", "hi"} {
if !strings.Contains(rendered, want) { 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)
} }
} }
} }
@@ -200,6 +303,8 @@ type fakeModelManager struct {
provider string provider string
model string model string
thinking string thinking string
sessionName string
saved bool
} }
func (f *fakeModelManager) CurrentProvider() string { func (f *fakeModelManager) CurrentProvider() string {
@@ -217,6 +322,17 @@ func (f *fakeModelManager) CurrentThinking() string {
return f.thinking 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 { func (f *fakeModelManager) Info() string {
return "provider: " + f.CurrentProvider() + "\nmodel: " + f.model + "\nthinking: " + f.thinking 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 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) { func TestParseThemeMode(t *testing.T) {
for input, want := range map[string]ThemeMode{ for input, want := range map[string]ThemeMode{
"": ThemeLight, "": ThemeLight,
+92 -2
View File
@@ -2,6 +2,8 @@ package tui
import ( import (
"strings" "strings"
"unicode"
"unicode/utf8"
"github.com/charmbracelet/glamour" "github.com/charmbracelet/glamour"
"github.com/charmbracelet/glamour/ansi" "github.com/charmbracelet/glamour/ansi"
@@ -24,7 +26,7 @@ func renderMarkdown(content string, width int, th theme) string {
if err != nil { if err != nil {
return content return content
} }
return strings.TrimRight(rendered, "\n") return trimRenderedMarkdown(rendered)
} }
func markdownStyle(th theme) ansi.StyleConfig { func markdownStyle(th theme) ansi.StyleConfig {
@@ -32,6 +34,7 @@ func markdownStyle(th theme) ansi.StyleConfig {
if th.Mode == ThemeDark { if th.Mode == ThemeDark {
cfg = glamstyles.DarkStyleConfig cfg = glamstyles.DarkStyleConfig
} }
clearMarkdownBackgrounds(&cfg)
zero := uint(0) zero := uint(0)
cfg.Document.Margin = &zero cfg.Document.Margin = &zero
@@ -51,7 +54,7 @@ func markdownStyle(th theme) ansi.StyleConfig {
cfg.LinkText.Color = stringPtr(th.User) cfg.LinkText.Color = stringPtr(th.User)
cfg.Code.Color = stringPtr(th.Tool) cfg.Code.Color = stringPtr(th.Tool)
cfg.Code.BackgroundColor = stringPtr(th.SurfaceAlt) cfg.Code.BackgroundColor = nil
cfg.CodeBlock.Color = stringPtr(th.Text) cfg.CodeBlock.Color = stringPtr(th.Text)
cfg.CodeBlock.BackgroundColor = nil cfg.CodeBlock.BackgroundColor = nil
cfg.CodeBlock.Margin = &zero cfg.CodeBlock.Margin = &zero
@@ -59,6 +62,93 @@ func markdownStyle(th theme) ansi.StyleConfig {
return cfg 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 { func stringPtr(value string) *string {
return &value return &value
} }
+20
View File
@@ -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]`) var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;:]*[A-Za-z]`)
func stripANSI(value string) string { func stripANSI(value string) string {
+27 -35
View File
@@ -46,8 +46,6 @@ type theme struct {
type styles struct { type styles struct {
App lipgloss.Style App lipgloss.Style
Header lipgloss.Style
Title lipgloss.Style
Muted lipgloss.Style Muted lipgloss.Style
Activity lipgloss.Style Activity lipgloss.Style
ModelMeta lipgloss.Style ModelMeta lipgloss.Style
@@ -56,7 +54,6 @@ type styles struct {
InputBox lipgloss.Style InputBox lipgloss.Style
CommandHint lipgloss.Style CommandHint lipgloss.Style
UserLabel lipgloss.Style UserLabel lipgloss.Style
AssistantLabel lipgloss.Style
ToolLabel lipgloss.Style ToolLabel lipgloss.Style
ErrorLabel lipgloss.Style ErrorLabel lipgloss.Style
SystemLabel lipgloss.Style SystemLabel lipgloss.Style
@@ -113,17 +110,7 @@ func darkTheme() theme {
func (t theme) styles() styles { func (t theme) styles() styles {
return styles{ return styles{
App: lipgloss.NewStyle(). App: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Text)). 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)),
Muted: lipgloss.NewStyle(). Muted: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)), Foreground(lipgloss.Color(t.Muted)),
@@ -141,19 +128,16 @@ func (t theme) styles() styles {
Footer: lipgloss.NewStyle(). Footer: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)). Foreground(lipgloss.Color(t.Muted)).
Background(lipgloss.Color(t.Background)).
Padding(0, 1), Padding(0, 1),
Viewport: lipgloss.NewStyle(). Viewport: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Text)). Foreground(lipgloss.Color(t.Text)),
Background(lipgloss.Color(t.Background)),
InputBox: lipgloss.NewStyle(). InputBox: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Text)). Foreground(lipgloss.Color(t.Text)).
Background(lipgloss.Color(t.Surface)). Border(lipgloss.ThickBorder()).
Border(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color(t.Border)). BorderForeground(lipgloss.Color(t.Border)).
Padding(0, 1), Padding(0, 2),
CommandHint: lipgloss.NewStyle(). CommandHint: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)). Foreground(lipgloss.Color(t.Muted)).
@@ -161,16 +145,15 @@ func (t theme) styles() styles {
Padding(0, 1), Padding(0, 1),
UserLabel: labelStyle(t.User), UserLabel: labelStyle(t.User),
AssistantLabel: labelStyle(t.Assistant),
ToolLabel: labelStyle(t.Tool), ToolLabel: labelStyle(t.Tool),
ErrorLabel: labelStyle(t.Error), ErrorLabel: labelStyle(t.Error),
SystemLabel: labelStyle(t.System), SystemLabel: labelStyle(t.System),
UserMessage: messageStyle(t.Text, t.User), UserMessage: bubbleMessageStyle(t.Text, t.SurfaceAlt),
AssistantMsg: messageStyle(t.Text, t.Assistant), AssistantMsg: assistantMessageStyle(t.Text),
ToolMessage: messageStyle(t.Text, t.Tool), ToolMessage: messageStyle(t.Text),
ErrorMessage: messageStyle(t.Error, t.Error), ErrorMessage: messageStyle(t.Error),
SystemMessage: lipgloss.NewStyle().Foreground(lipgloss.Color(t.System)).PaddingLeft(1), 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)) Foreground(lipgloss.Color(color))
} }
func messageStyle(textColor, accentColor string) lipgloss.Style { func messageStyle(textColor string) lipgloss.Style {
return lipgloss.NewStyle(). return lipgloss.NewStyle().
Foreground(lipgloss.Color(textColor)). Foreground(lipgloss.Color(textColor)).
Border(lipgloss.ThickBorder(), false, false, false, true). Padding(0, 1)
BorderForeground(lipgloss.Color(accentColor)). }
PaddingLeft(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) { func applyTextareaTheme(input *textarea.Model, t theme) {
focused := textarea.Style{ focused := textarea.Style{
Base: lipgloss.NewStyle(). Base: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Text)). Foreground(lipgloss.Color(t.Text)),
Background(lipgloss.Color(t.Surface)), CursorLine: lipgloss.NewStyle(),
CursorLine: lipgloss.NewStyle().
Background(lipgloss.Color(t.Surface)),
Placeholder: lipgloss.NewStyle(). Placeholder: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)), Foreground(lipgloss.Color(t.Muted)),
Prompt: lipgloss.NewStyle(). Prompt: lipgloss.NewStyle().
@@ -205,7 +197,7 @@ func applyTextareaTheme(input *textarea.Model, t theme) {
Foreground(lipgloss.Color(t.Surface)), Foreground(lipgloss.Color(t.Surface)),
} }
blurred := focused blurred := focused
blurred.CursorLine = lipgloss.NewStyle().Background(lipgloss.Color(t.Surface)) blurred.CursorLine = lipgloss.NewStyle()
input.FocusedStyle = focused input.FocusedStyle = focused
input.BlurredStyle = blurred input.BlurredStyle = blurred