v0.0.4: add sessions and refine tui
This commit is contained in:
@@ -2,3 +2,4 @@ agentu*.yaml
|
|||||||
/agentu
|
/agentu
|
||||||
/coverage.out
|
/coverage.out
|
||||||
*.swp
|
*.swp
|
||||||
|
chat-history.txt
|
||||||
|
|||||||
+51
-4
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
+165
-8
@@ -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"
|
||||||
@@ -12,14 +13,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
agent *agent.Agent
|
agent *agent.Agent
|
||||||
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] + "..."
|
||||||
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -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
@@ -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{}
|
||||||
|
|||||||
+154
-16
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,9 +300,11 @@ func TestModelThinkingCommand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type fakeModelManager struct {
|
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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+48
-56
@@ -45,26 +45,23 @@ type theme struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type styles struct {
|
type styles struct {
|
||||||
App lipgloss.Style
|
App lipgloss.Style
|
||||||
Header lipgloss.Style
|
Muted lipgloss.Style
|
||||||
Title lipgloss.Style
|
Activity lipgloss.Style
|
||||||
Muted lipgloss.Style
|
ModelMeta lipgloss.Style
|
||||||
Activity lipgloss.Style
|
Footer lipgloss.Style
|
||||||
ModelMeta lipgloss.Style
|
Viewport lipgloss.Style
|
||||||
Footer lipgloss.Style
|
InputBox lipgloss.Style
|
||||||
Viewport lipgloss.Style
|
CommandHint lipgloss.Style
|
||||||
InputBox lipgloss.Style
|
UserLabel lipgloss.Style
|
||||||
CommandHint lipgloss.Style
|
ToolLabel lipgloss.Style
|
||||||
UserLabel lipgloss.Style
|
ErrorLabel lipgloss.Style
|
||||||
AssistantLabel lipgloss.Style
|
SystemLabel lipgloss.Style
|
||||||
ToolLabel lipgloss.Style
|
UserMessage lipgloss.Style
|
||||||
ErrorLabel lipgloss.Style
|
AssistantMsg lipgloss.Style
|
||||||
SystemLabel lipgloss.Style
|
ToolMessage lipgloss.Style
|
||||||
UserMessage lipgloss.Style
|
ErrorMessage lipgloss.Style
|
||||||
AssistantMsg lipgloss.Style
|
SystemMessage lipgloss.Style
|
||||||
ToolMessage lipgloss.Style
|
|
||||||
ErrorMessage lipgloss.Style
|
|
||||||
SystemMessage lipgloss.Style
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func themeForMode(mode ThemeMode) theme {
|
func themeForMode(mode ThemeMode) theme {
|
||||||
@@ -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,36 +128,32 @@ 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)).
|
||||||
Background(lipgloss.Color(t.SurfaceAlt)).
|
Background(lipgloss.Color(t.SurfaceAlt)).
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user