From 7020b6dbcd599978f903dc88abc8d45c04e34b67 Mon Sep 17 00:00:00 2001
From: Qasim
Date: Thu, 12 Feb 2026 22:02:51 -0500
Subject: [PATCH 1/9] feat(chat): add AI chat interface using local CLI agents
Add `nylas chat` command that launches a web-based AI chat interface
for interacting with email, calendar, and contacts through locally
installed AI agents (Claude, Codex, Ollama).
Key features:
- Auto-detects installed AI agents via PATH lookup
- Text-based tool protocol (TOOL_CALL/TOOL_RESULT) for agent-API bridge
- SSE streaming for real-time chat responses
- Persistent conversation history as JSON files on disk
- Context window management with automatic compaction
- Agent switching via dropdown without restart
- Modern dark/light theme web UI
---
CLAUDE.md | 5 +-
cmd/nylas/main.go | 2 +
docs/COMMANDS.md | 58 +++
internal/chat/agent.go | 182 ++++++++++
internal/chat/agent_test.go | 257 +++++++++++++
internal/chat/chat.go | 118 ++++++
internal/chat/context.go | 161 +++++++++
internal/chat/context_test.go | 426 ++++++++++++++++++++++
internal/chat/executor.go | 387 ++++++++++++++++++++
internal/chat/handlers.go | 273 ++++++++++++++
internal/chat/handlers_conv.go | 88 +++++
internal/chat/memory.go | 244 +++++++++++++
internal/chat/memory_test.go | 513 ++++++++++++++++++++++++++
internal/chat/prompt.go | 46 +++
internal/chat/server.go | 127 +++++++
internal/chat/session.go | 28 ++
internal/chat/static/css/chat.css | 519 +++++++++++++++++++++++++++
internal/chat/static/js/api.js | 76 ++++
internal/chat/static/js/chat.js | 210 +++++++++++
internal/chat/static/js/markdown.js | 51 +++
internal/chat/static/js/sidebar.js | 133 +++++++
internal/chat/templates/index.gohtml | 65 ++++
internal/chat/tools.go | 166 +++++++++
internal/chat/tools_test.go | 354 ++++++++++++++++++
24 files changed, 4488 insertions(+), 1 deletion(-)
create mode 100644 internal/chat/agent.go
create mode 100644 internal/chat/agent_test.go
create mode 100644 internal/chat/chat.go
create mode 100644 internal/chat/context.go
create mode 100644 internal/chat/context_test.go
create mode 100644 internal/chat/executor.go
create mode 100644 internal/chat/handlers.go
create mode 100644 internal/chat/handlers_conv.go
create mode 100644 internal/chat/memory.go
create mode 100644 internal/chat/memory_test.go
create mode 100644 internal/chat/prompt.go
create mode 100644 internal/chat/server.go
create mode 100644 internal/chat/session.go
create mode 100644 internal/chat/static/css/chat.css
create mode 100644 internal/chat/static/js/api.js
create mode 100644 internal/chat/static/js/chat.js
create mode 100644 internal/chat/static/js/markdown.js
create mode 100644 internal/chat/static/js/sidebar.js
create mode 100644 internal/chat/templates/index.gohtml
create mode 100644 internal/chat/tools.go
create mode 100644 internal/chat/tools_test.go
diff --git a/CLAUDE.md b/CLAUDE.md
index 96e3125..fc93789 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -65,6 +65,7 @@ make ci # Runs: fmt → vet → lint → test-unit → test-race → secu
- **API**: Nylas v3 ONLY (never use v1/v2)
- **Timezone Support**: Offline utilities + calendar integration ✅
- **Email Signing**: GPG/PGP email signing (RFC 3156 PGP/MIME) ✅
+- **AI Chat**: Web-based chat interface using locally installed AI agents ✅
- **Credential Storage**: System keyring (see below)
- **Web UI**: Air - browser-based interface (localhost:7365)
@@ -122,7 +123,7 @@ Credentials from `nylas auth config` are stored in the system keyring under serv
**Core files:** `cmd/nylas/main.go`, `internal/ports/nylas.go`, `internal/adapters/nylas/client.go`
-**Quick lookup:** CLI helpers in `internal/cli/common/`, HTTP in `client.go`, Air at `internal/air/`
+**Quick lookup:** CLI helpers in `internal/cli/common/`, HTTP in `client.go`, Air at `internal/air/`, Chat at `internal/chat/`
**New packages (2024-2026):**
- `internal/ports/output.go` - OutputWriter interface for pluggable formatting
@@ -130,6 +131,7 @@ Credentials from `nylas auth config` are stored in the system keyring under serv
- `internal/httputil/` - HTTP response helpers (WriteJSON, LimitedBody, DecodeJSON)
- `internal/adapters/gpg/` - GPG/PGP email signing service (2026)
- `internal/adapters/mime/` - RFC 3156 PGP/MIME message builder (2026)
+- `internal/chat/` - AI chat interface with local agent support (2026)
**Full inventory:** `docs/ARCHITECTURE.md`
@@ -195,6 +197,7 @@ Credentials from `nylas auth config` are stored in the system keyring under serv
| `make ci` | Quick quality checks (no integration) |
| `make build` | Build binary |
| `nylas air` | Start Air web UI (localhost:7365) |
+| `nylas chat` | Start AI chat interface (localhost:7367) |
**Available targets:** Run `make help` or `make` to see all available commands
diff --git a/cmd/nylas/main.go b/cmd/nylas/main.go
index cd19c5c..46edb52 100644
--- a/cmd/nylas/main.go
+++ b/cmd/nylas/main.go
@@ -6,6 +6,7 @@ import (
"os"
"github.com/nylas/cli/internal/air"
+ "github.com/nylas/cli/internal/chat"
"github.com/nylas/cli/internal/cli"
"github.com/nylas/cli/internal/cli/admin"
"github.com/nylas/cli/internal/cli/ai"
@@ -54,6 +55,7 @@ func main() {
rootCmd.AddCommand(cli.NewTUICmd())
rootCmd.AddCommand(ui.NewUICmd())
rootCmd.AddCommand(air.NewAirCmd())
+ rootCmd.AddCommand(chat.NewChatCmd())
rootCmd.AddCommand(update.NewUpdateCmd())
if err := cli.Execute(); err != nil {
diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md
index 0ed072d..ca41811 100644
--- a/docs/COMMANDS.md
+++ b/docs/COMMANDS.md
@@ -430,6 +430,64 @@ make test-air-integration # Run Air integration tests only
---
+## Chat (`nylas chat`) - AI Chat Interface
+
+Launch **Nylas Chat** - a web-based AI chat interface that can access your email, calendar, and contacts:
+
+```bash
+nylas chat # Start with auto-detected agent (default port 7367)
+nylas chat --agent claude # Use specific agent (claude, codex, ollama)
+nylas chat --agent ollama --model llama2 # Use Ollama with specific model
+nylas chat --port 8080 # Custom port
+nylas chat --no-browser # Don't auto-open browser
+```
+
+**Features:**
+- **Local AI agents:** Uses Claude, Codex, or Ollama installed on your system
+- **Email & Calendar access:** AI can read emails, check calendar, manage contacts
+- **Conversation history:** Persistent chat sessions stored locally
+- **Agent switching:** Change agents without restarting
+- **Web interface:** Clean, modern chat UI
+
+**Supported Agents:**
+| Agent | Description | Auto-detected |
+|-------|-------------|---------------|
+| Claude | Anthropic's Claude (via `claude` CLI) | ✅ |
+| Codex | OpenAI Codex | ✅ |
+| Ollama | Local LLM runner (customizable models) | ✅ |
+
+**Agent Detection:**
+The CLI automatically detects installed agents on your system. Use `--agent` to override the default selection.
+
+**Conversation Storage:**
+- Location: `~/.config/nylas/chat/conversations/`
+- Format: JSON files per conversation
+- Persistent across sessions
+
+**Security:**
+- Runs on localhost only (not accessible externally)
+- All data stored locally on your machine
+- Agent communication happens through local processes
+
+**URL:** `http://localhost:7367` (default)
+
+**Examples:**
+```bash
+# Quick start with best available agent
+nylas chat
+
+# Force use of Claude
+nylas chat --agent claude
+
+# Use Ollama with Mistral model
+nylas chat --agent ollama --model mistral
+
+# Run on different port
+nylas chat --port 9000 --no-browser
+```
+
+---
+
## MCP (Model Context Protocol)
Enable AI assistants (Claude Desktop, Cursor, Windsurf, VS Code) to interact with your email and calendar.
diff --git a/internal/chat/agent.go b/internal/chat/agent.go
new file mode 100644
index 0000000..59bbaad
--- /dev/null
+++ b/internal/chat/agent.go
@@ -0,0 +1,182 @@
+// Package chat provides an AI chat interface using locally installed CLI agents.
+package chat
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+)
+
+// AgentType represents a supported AI agent.
+type AgentType string
+
+const (
+ AgentClaude AgentType = "claude"
+ AgentCodex AgentType = "codex"
+ AgentOllama AgentType = "ollama"
+)
+
+// Agent represents a detected AI agent on the system.
+type Agent struct {
+ Type AgentType `json:"type"`
+ Path string `json:"path"`
+ Model string `json:"model,omitempty"` // for ollama: model name
+ Version string `json:"version,omitempty"` // detected version
+}
+
+// DetectAgents scans the system for installed AI agents.
+// It checks for claude, codex, and ollama in $PATH.
+func DetectAgents() []Agent {
+ var agents []Agent
+
+ checks := []struct {
+ name AgentType
+ binary string
+ versionArgs []string
+ }{
+ {AgentClaude, "claude", []string{"--version"}},
+ {AgentCodex, "codex", []string{"--version"}},
+ {AgentOllama, "ollama", []string{"--version"}},
+ }
+
+ for _, check := range checks {
+ path, err := exec.LookPath(check.binary)
+ if err != nil {
+ continue
+ }
+
+ agent := Agent{
+ Type: check.name,
+ Path: path,
+ }
+
+ // Try to get version
+ if len(check.versionArgs) > 0 {
+ out, err := exec.Command(path, check.versionArgs...).Output()
+ if err == nil {
+ agent.Version = strings.TrimSpace(string(out))
+ }
+ }
+
+ // Default model for ollama
+ if check.name == AgentOllama {
+ agent.Model = "mistral"
+ }
+
+ agents = append(agents, agent)
+ }
+
+ return agents
+}
+
+// FindAgent returns the first agent matching the given type, or nil.
+func FindAgent(agents []Agent, agentType AgentType) *Agent {
+ for i := range agents {
+ if agents[i].Type == agentType {
+ return &agents[i]
+ }
+ }
+ return nil
+}
+
+// Run executes the agent with the given prompt and returns the response.
+// Each agent type has a different invocation pattern:
+// - claude: claude -p --output-format text "prompt"
+// - codex: codex exec "prompt"
+// - ollama: echo "prompt" | ollama run
+func (a *Agent) Run(ctx context.Context, prompt string) (string, error) {
+ switch a.Type {
+ case AgentClaude:
+ return a.runClaude(ctx, prompt)
+ case AgentCodex:
+ return a.runCodex(ctx, prompt)
+ case AgentOllama:
+ return a.runOllama(ctx, prompt)
+ default:
+ return "", fmt.Errorf("unsupported agent type: %s", a.Type)
+ }
+}
+
+// cleanEnv returns the current environment with nesting-detection vars removed
+// so agent subprocesses don't refuse to start inside our server process.
+func cleanEnv() []string {
+ skip := map[string]bool{
+ "CLAUDECODE": true,
+ "CLAUDE_CODE": true,
+ "CODEX_SANDBOX": true,
+ "CODEX_ENV": true,
+ "INSIDE_CODEX": true,
+ }
+
+ var env []string
+ for _, e := range os.Environ() {
+ key, _, _ := strings.Cut(e, "=")
+ if !skip[key] {
+ env = append(env, e)
+ }
+ }
+ return env
+}
+
+func (a *Agent) runClaude(ctx context.Context, prompt string) (string, error) {
+ cmd := exec.CommandContext(ctx, a.Path, "-p", "--output-format", "text", prompt)
+ cmd.Env = cleanEnv()
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+
+ if err := cmd.Run(); err != nil {
+ return "", fmt.Errorf("claude error: %w: %s", err, stderr.String())
+ }
+
+ return strings.TrimSpace(stdout.String()), nil
+}
+
+func (a *Agent) runCodex(ctx context.Context, prompt string) (string, error) {
+ cmd := exec.CommandContext(ctx, a.Path, "exec", prompt)
+ cmd.Env = cleanEnv()
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+
+ if err := cmd.Run(); err != nil {
+ return "", fmt.Errorf("codex error: %w: %s", err, stderr.String())
+ }
+
+ return strings.TrimSpace(stdout.String()), nil
+}
+
+func (a *Agent) runOllama(ctx context.Context, prompt string) (string, error) {
+ model := a.Model
+ if model == "" {
+ model = "mistral"
+ }
+
+ cmd := exec.CommandContext(ctx, a.Path, "run", model)
+ cmd.Env = cleanEnv()
+ cmd.Stdin = strings.NewReader(prompt)
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+
+ if err := cmd.Run(); err != nil {
+ return "", fmt.Errorf("ollama error: %w: %s", err, stderr.String())
+ }
+
+ return strings.TrimSpace(stdout.String()), nil
+}
+
+// String returns a human-readable description of the agent.
+func (a *Agent) String() string {
+ s := string(a.Type)
+ if a.Model != "" {
+ s += " (" + a.Model + ")"
+ }
+ if a.Version != "" {
+ s += " " + a.Version
+ }
+ return s
+}
diff --git a/internal/chat/agent_test.go b/internal/chat/agent_test.go
new file mode 100644
index 0000000..74ce3d5
--- /dev/null
+++ b/internal/chat/agent_test.go
@@ -0,0 +1,257 @@
+package chat
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestFindAgent(t *testing.T) {
+ agents := []Agent{
+ {Type: AgentClaude, Path: "/usr/bin/claude", Version: "1.0.0"},
+ {Type: AgentOllama, Path: "/usr/local/bin/ollama", Model: "mistral"},
+ {Type: AgentCodex, Path: "/opt/codex", Version: "2.1.0"},
+ }
+
+ tests := []struct {
+ name string
+ agentType AgentType
+ wantFound bool
+ validate func(t *testing.T, agent *Agent)
+ }{
+ {
+ name: "finds claude agent",
+ agentType: AgentClaude,
+ wantFound: true,
+ validate: func(t *testing.T, agent *Agent) {
+ assert.Equal(t, AgentClaude, agent.Type)
+ assert.Equal(t, "/usr/bin/claude", agent.Path)
+ assert.Equal(t, "1.0.0", agent.Version)
+ },
+ },
+ {
+ name: "finds ollama agent",
+ agentType: AgentOllama,
+ wantFound: true,
+ validate: func(t *testing.T, agent *Agent) {
+ assert.Equal(t, AgentOllama, agent.Type)
+ assert.Equal(t, "/usr/local/bin/ollama", agent.Path)
+ assert.Equal(t, "mistral", agent.Model)
+ },
+ },
+ {
+ name: "finds codex agent",
+ agentType: AgentCodex,
+ wantFound: true,
+ validate: func(t *testing.T, agent *Agent) {
+ assert.Equal(t, AgentCodex, agent.Type)
+ assert.Equal(t, "/opt/codex", agent.Path)
+ assert.Equal(t, "2.1.0", agent.Version)
+ },
+ },
+ {
+ name: "returns nil for non-existent agent",
+ agentType: "nonexistent",
+ wantFound: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ agent := FindAgent(agents, tt.agentType)
+
+ if tt.wantFound {
+ require.NotNil(t, agent, "agent should be found")
+ if tt.validate != nil {
+ tt.validate(t, agent)
+ }
+ } else {
+ assert.Nil(t, agent, "agent should not be found")
+ }
+ })
+ }
+}
+
+func TestFindAgent_EmptyList(t *testing.T) {
+ agents := []Agent{}
+ agent := FindAgent(agents, AgentClaude)
+ assert.Nil(t, agent, "should return nil for empty agent list")
+}
+
+func TestFindAgent_FirstMatch(t *testing.T) {
+ // Test that it returns the first matching agent
+ agents := []Agent{
+ {Type: AgentClaude, Path: "/usr/bin/claude", Version: "1.0"},
+ {Type: AgentClaude, Path: "/usr/local/bin/claude", Version: "2.0"},
+ {Type: AgentOllama, Path: "/usr/bin/ollama"},
+ }
+
+ agent := FindAgent(agents, AgentClaude)
+ require.NotNil(t, agent)
+ assert.Equal(t, "/usr/bin/claude", agent.Path, "should return first matching agent")
+ assert.Equal(t, "1.0", agent.Version)
+}
+
+func TestAgent_String(t *testing.T) {
+ tests := []struct {
+ name string
+ agent Agent
+ expected string
+ }{
+ {
+ name: "claude with version",
+ agent: Agent{
+ Type: AgentClaude,
+ Path: "/usr/bin/claude",
+ Version: "1.0.0",
+ },
+ expected: "claude 1.0.0",
+ },
+ {
+ name: "ollama with model and version",
+ agent: Agent{
+ Type: AgentOllama,
+ Path: "/usr/bin/ollama",
+ Model: "mistral",
+ Version: "0.1.0",
+ },
+ expected: "ollama (mistral) 0.1.0",
+ },
+ {
+ name: "ollama with model only",
+ agent: Agent{
+ Type: AgentOllama,
+ Path: "/usr/bin/ollama",
+ Model: "llama2",
+ },
+ expected: "ollama (llama2)",
+ },
+ {
+ name: "codex without version or model",
+ agent: Agent{
+ Type: AgentCodex,
+ Path: "/opt/codex",
+ },
+ expected: "codex",
+ },
+ {
+ name: "agent with version but no model",
+ agent: Agent{
+ Type: AgentClaude,
+ Path: "/usr/bin/claude",
+ Version: "2.5.1",
+ },
+ expected: "claude 2.5.1",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := tt.agent.String()
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestAgent_String_AllAgentTypes(t *testing.T) {
+ agentTypes := []AgentType{AgentClaude, AgentCodex, AgentOllama}
+
+ for _, agentType := range agentTypes {
+ t.Run(string(agentType), func(t *testing.T) {
+ agent := Agent{Type: agentType, Path: "/usr/bin/test"}
+ str := agent.String()
+ assert.Contains(t, str, string(agentType), "string representation should contain agent type")
+ })
+ }
+}
+
+func TestAgentType_Constants(t *testing.T) {
+ t.Run("agent type values", func(t *testing.T) {
+ assert.Equal(t, AgentType("claude"), AgentClaude)
+ assert.Equal(t, AgentType("codex"), AgentCodex)
+ assert.Equal(t, AgentType("ollama"), AgentOllama)
+ })
+
+ t.Run("agent types are unique", func(t *testing.T) {
+ types := map[AgentType]bool{
+ AgentClaude: true,
+ AgentCodex: true,
+ AgentOllama: true,
+ }
+ assert.Equal(t, 3, len(types), "all agent types should be unique")
+ })
+}
+
+func TestDetectAgents_Structure(t *testing.T) {
+ // This test validates the structure without requiring actual binaries
+ // We can't test the actual detection without mocking exec.LookPath
+ t.Run("returns slice of agents", func(t *testing.T) {
+ agents := DetectAgents()
+ assert.NotNil(t, agents, "should return non-nil slice")
+ // May be empty if no agents are installed
+ })
+
+ t.Run("detected agents have required fields", func(t *testing.T) {
+ agents := DetectAgents()
+ for _, agent := range agents {
+ assert.NotEmpty(t, agent.Type, "agent should have a type")
+ assert.NotEmpty(t, agent.Path, "agent should have a path")
+ // Version and Model are optional
+ }
+ })
+
+ t.Run("ollama agents have default model", func(t *testing.T) {
+ agents := DetectAgents()
+ for _, agent := range agents {
+ if agent.Type == AgentOllama {
+ assert.Equal(t, "mistral", agent.Model, "ollama should have default model 'mistral'")
+ }
+ }
+ })
+}
+
+func TestAgent_Fields(t *testing.T) {
+ t.Run("agent with all fields", func(t *testing.T) {
+ agent := Agent{
+ Type: AgentClaude,
+ Path: "/usr/bin/claude",
+ Model: "claude-3",
+ Version: "3.0.0",
+ }
+
+ assert.Equal(t, AgentClaude, agent.Type)
+ assert.Equal(t, "/usr/bin/claude", agent.Path)
+ assert.Equal(t, "claude-3", agent.Model)
+ assert.Equal(t, "3.0.0", agent.Version)
+ })
+
+ t.Run("agent with minimal fields", func(t *testing.T) {
+ agent := Agent{
+ Type: AgentCodex,
+ Path: "/opt/codex",
+ }
+
+ assert.Equal(t, AgentCodex, agent.Type)
+ assert.Equal(t, "/opt/codex", agent.Path)
+ assert.Empty(t, agent.Model)
+ assert.Empty(t, agent.Version)
+ })
+}
+
+func TestFindAgent_PointerSafety(t *testing.T) {
+ agents := []Agent{
+ {Type: AgentClaude, Path: "/usr/bin/claude"},
+ }
+
+ agent1 := FindAgent(agents, AgentClaude)
+ agent2 := FindAgent(agents, AgentClaude)
+
+ // Both should point to the same agent in the slice
+ require.NotNil(t, agent1)
+ require.NotNil(t, agent2)
+
+ // Modifying via pointer should affect the original
+ agent1.Version = "modified"
+ assert.Equal(t, "modified", agents[0].Version, "pointer should reference original agent")
+}
diff --git a/internal/chat/chat.go b/internal/chat/chat.go
new file mode 100644
index 0000000..5c54c63
--- /dev/null
+++ b/internal/chat/chat.go
@@ -0,0 +1,118 @@
+package chat
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/spf13/cobra"
+
+ browserpkg "github.com/nylas/cli/internal/adapters/browser"
+ "github.com/nylas/cli/internal/adapters/config"
+ "github.com/nylas/cli/internal/cli/common"
+)
+
+// NewChatCmd creates the chat command.
+func NewChatCmd() *cobra.Command {
+ var (
+ port int
+ noBrowser bool
+ agentName string
+ model string
+ )
+
+ cmd := &cobra.Command{
+ Use: "chat",
+ Short: "Chat with AI using your email and calendar",
+ Long: `Launch an AI chat interface that can access your Nylas email, calendar, and contacts.
+
+Uses locally installed AI agents (Claude, Codex, or Ollama) to answer questions
+and perform actions on your behalf through a web-based chat interface.`,
+ Example: ` # Launch chat (auto-detects best agent)
+ nylas chat
+
+ # Use a specific agent
+ nylas chat --agent claude
+
+ # Use Ollama with a specific model
+ nylas chat --agent ollama --model llama2
+
+ # Launch on custom port without browser
+ nylas chat --port 8080 --no-browser`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ // Detect available agents
+ agents := DetectAgents()
+ if len(agents) == 0 {
+ return fmt.Errorf("no AI agents found; install claude, codex, or ollama")
+ }
+
+ // Select agent
+ var agent *Agent
+ if agentName != "" {
+ agent = FindAgent(agents, AgentType(agentName))
+ if agent == nil {
+ return fmt.Errorf("agent %q not found; available: %v", agentName, agentNames(agents))
+ }
+ } else {
+ agent = &agents[0] // use first detected
+ }
+
+ // Override model for ollama
+ if model != "" && agent.Type == AgentOllama {
+ agent.Model = model
+ }
+
+ // Set up Nylas client
+ nylasClient, err := common.GetNylasClient()
+ if err != nil {
+ return err
+ }
+
+ grantID, err := common.GetGrantID(args)
+ if err != nil {
+ return err
+ }
+
+ // Set up conversation storage
+ chatDir := filepath.Join(config.DefaultConfigDir(), "chat", "conversations")
+ memory, err := NewMemoryStore(chatDir)
+ if err != nil {
+ return fmt.Errorf("initialize chat storage: %w", err)
+ }
+
+ addr := fmt.Sprintf("localhost:%d", port)
+ url := fmt.Sprintf("http://%s", addr)
+
+ fmt.Printf("Starting Nylas Chat at %s\n", url)
+ fmt.Printf("Agent: %s\n", agent)
+ fmt.Println("Press Ctrl+C to stop")
+ fmt.Println()
+
+ if !noBrowser {
+ b := browserpkg.NewDefaultBrowser()
+ if err := b.Open(url); err != nil {
+ fmt.Fprintf(os.Stderr, "Could not open browser: %v\n", err)
+ fmt.Printf("Open %s manually\n", url)
+ }
+ }
+
+ server := NewServer(addr, agent, agents, nylasClient, grantID, memory)
+ return server.Start()
+ },
+ }
+
+ cmd.Flags().IntVarP(&port, "port", "p", 7367, "Port to run the server on")
+ cmd.Flags().BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically")
+ cmd.Flags().StringVar(&agentName, "agent", "", "AI agent to use (claude, codex, ollama)")
+ cmd.Flags().StringVar(&model, "model", "", "Model name for ollama agent")
+
+ return cmd
+}
+
+func agentNames(agents []Agent) []string {
+ names := make([]string, len(agents))
+ for i, a := range agents {
+ names[i] = string(a.Type)
+ }
+ return names
+}
diff --git a/internal/chat/context.go b/internal/chat/context.go
new file mode 100644
index 0000000..aaa062c
--- /dev/null
+++ b/internal/chat/context.go
@@ -0,0 +1,161 @@
+package chat
+
+import (
+ "context"
+ "strings"
+)
+
+const (
+ // compactionThreshold is the number of user+assistant messages before compaction.
+ compactionThreshold = 30
+
+ // compactionWindow is the number of recent messages to keep after compaction.
+ compactionWindow = 15
+)
+
+// ContextBuilder constructs prompts with conversation context and manages compaction.
+type ContextBuilder struct {
+ agent *Agent
+ memory *MemoryStore
+ grantID string
+}
+
+// NewContextBuilder creates a new ContextBuilder.
+func NewContextBuilder(agent *Agent, memory *MemoryStore, grantID string) *ContextBuilder {
+ return &ContextBuilder{
+ agent: agent,
+ memory: memory,
+ grantID: grantID,
+ }
+}
+
+// BuildPrompt constructs the full prompt for the agent including:
+// 1. System prompt (identity + tools + instructions)
+// 2. Conversation summary (if compacted)
+// 3. Recent messages (within context window)
+// 4. Latest user message
+func (c *ContextBuilder) BuildPrompt(conv *Conversation, newMessage string) string {
+ var sb strings.Builder
+
+ // System prompt
+ sb.WriteString(BuildSystemPrompt(c.grantID, c.agent.Type))
+ sb.WriteString("\n---\n\n")
+
+ // Include conversation summary if available
+ if conv.Summary != "" {
+ sb.WriteString("## Previous Conversation Summary\n\n")
+ sb.WriteString(conv.Summary)
+ sb.WriteString("\n\n---\n\n")
+ }
+
+ // Include recent messages
+ sb.WriteString("## Conversation\n\n")
+ for _, msg := range conv.Messages {
+ switch msg.Role {
+ case "user":
+ sb.WriteString("User: ")
+ sb.WriteString(msg.Content)
+ sb.WriteString("\n\n")
+ case "assistant":
+ sb.WriteString("Assistant: ")
+ sb.WriteString(msg.Content)
+ sb.WriteString("\n\n")
+ case "tool_call":
+ sb.WriteString(toolCallPrefix + " ")
+ sb.WriteString(msg.Content)
+ sb.WriteString("\n\n")
+ case "tool_result":
+ sb.WriteString(toolResultPrefix + " ")
+ sb.WriteString(msg.Content)
+ sb.WriteString("\n\n")
+ }
+ }
+
+ // Add new user message
+ sb.WriteString("User: ")
+ sb.WriteString(newMessage)
+ sb.WriteString("\n\nAssistant: ")
+
+ return sb.String()
+}
+
+// NeedsCompaction checks if the conversation should be compacted.
+// Returns true when there are more than compactionThreshold user+assistant messages.
+func (c *ContextBuilder) NeedsCompaction(conv *Conversation) bool {
+ count := 0
+ for _, msg := range conv.Messages {
+ if msg.Role == "user" || msg.Role == "assistant" {
+ count++
+ }
+ }
+ return count > compactionThreshold
+}
+
+// Compact summarizes older messages and trims the conversation.
+// It keeps the most recent compactionWindow messages and summarizes the rest.
+func (c *ContextBuilder) Compact(ctx context.Context, conv *Conversation) error {
+ if !c.NeedsCompaction(conv) {
+ return nil
+ }
+
+ // Find the split point: keep last compactionWindow user+assistant messages
+ splitIdx := c.findSplitIndex(conv)
+ if splitIdx <= 0 {
+ return nil
+ }
+
+ // Build the older messages into text for summarization
+ var older strings.Builder
+ for _, msg := range conv.Messages[:splitIdx] {
+ if msg.Role == "user" || msg.Role == "assistant" {
+ older.WriteString(msg.Role + ": " + msg.Content + "\n")
+ }
+ }
+
+ // Ask the agent to summarize
+ prompt := "Summarize this conversation so far in 3-4 sentences. " +
+ "Preserve key facts, names, email IDs, dates, and any commitments made.\n\n" +
+ older.String()
+
+ summary, err := c.agent.Run(ctx, prompt)
+ if err != nil {
+ return err
+ }
+
+ // Merge with existing summary if present
+ if conv.Summary != "" {
+ summary = conv.Summary + "\n\n" + summary
+ }
+
+ // Update memory store with summary and trim messages
+ return c.memory.UpdateSummary(conv.ID, summary, splitIdx)
+}
+
+// findSplitIndex finds the index to split at, keeping the last compactionWindow
+// user+assistant messages intact.
+func (c *ContextBuilder) findSplitIndex(conv *Conversation) int {
+ count := 0
+ for _, msg := range conv.Messages {
+ if msg.Role == "user" || msg.Role == "assistant" {
+ count++
+ }
+ }
+
+ keep := compactionWindow
+ if count <= keep {
+ return 0
+ }
+
+ // Walk backwards to find where to split
+ seen := 0
+ for i := len(conv.Messages) - 1; i >= 0; i-- {
+ if conv.Messages[i].Role == "user" || conv.Messages[i].Role == "assistant" {
+ seen++
+ }
+ if seen >= keep {
+ return i
+ }
+ }
+
+ return 0
+}
diff --git a/internal/chat/context_test.go b/internal/chat/context_test.go
new file mode 100644
index 0000000..8dd590b
--- /dev/null
+++ b/internal/chat/context_test.go
@@ -0,0 +1,426 @@
+package chat
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestContextBuilder_BuildPrompt(t *testing.T) {
+ agent := &Agent{Type: AgentClaude, Path: "/usr/bin/claude"}
+ store := setupMemoryStore(t)
+ grantID := "test-grant-123"
+ builder := NewContextBuilder(agent, store, grantID)
+
+ tests := []struct {
+ name string
+ setupConv func(t *testing.T) *Conversation
+ newMessage string
+ validate func(t *testing.T, prompt string)
+ }{
+ {
+ name: "empty conversation",
+ setupConv: func(t *testing.T) *Conversation {
+ return &Conversation{
+ ID: "conv_test",
+ Messages: []Message{},
+ }
+ },
+ newMessage: "Hello",
+ validate: func(t *testing.T, prompt string) {
+ assert.Contains(t, prompt, "You are a helpful email and calendar assistant")
+ assert.Contains(t, prompt, "Available tools:")
+ assert.Contains(t, prompt, "User: Hello")
+ assert.Contains(t, prompt, "Assistant:")
+ assert.NotContains(t, prompt, "Previous Conversation Summary")
+ },
+ },
+ {
+ name: "conversation with messages",
+ setupConv: func(t *testing.T) *Conversation {
+ return &Conversation{
+ ID: "conv_test",
+ Messages: []Message{
+ {Role: "user", Content: "What's the weather?", Timestamp: time.Now()},
+ {Role: "assistant", Content: "I can help with that.", Timestamp: time.Now()},
+ },
+ }
+ },
+ newMessage: "Thanks",
+ validate: func(t *testing.T, prompt string) {
+ assert.Contains(t, prompt, "User: What's the weather?")
+ assert.Contains(t, prompt, "Assistant: I can help with that.")
+ assert.Contains(t, prompt, "User: Thanks")
+ assert.Contains(t, prompt, "Assistant:")
+ },
+ },
+ {
+ name: "conversation with summary",
+ setupConv: func(t *testing.T) *Conversation {
+ return &Conversation{
+ ID: "conv_test",
+ Summary: "The user asked about emails and I helped them search for budget-related messages.",
+ Messages: []Message{
+ {Role: "user", Content: "Show me recent emails", Timestamp: time.Now()},
+ },
+ }
+ },
+ newMessage: "What about calendar?",
+ validate: func(t *testing.T, prompt string) {
+ assert.Contains(t, prompt, "Previous Conversation Summary")
+ assert.Contains(t, prompt, "budget-related messages")
+ assert.Contains(t, prompt, "User: Show me recent emails")
+ assert.Contains(t, prompt, "User: What about calendar?")
+ },
+ },
+ {
+ name: "conversation with tool calls and results",
+ setupConv: func(t *testing.T) *Conversation {
+ return &Conversation{
+ ID: "conv_test",
+ Messages: []Message{
+ {Role: "user", Content: "List my emails", Timestamp: time.Now()},
+ {Role: "tool_call", Content: `{"name":"list_emails","args":{}}`, Name: "list_emails", Timestamp: time.Now()},
+ {Role: "tool_result", Content: `{"name":"list_emails","data":[]}`, Name: "list_emails", Timestamp: time.Now()},
+ {Role: "assistant", Content: "You have no emails.", Timestamp: time.Now()},
+ },
+ }
+ },
+ newMessage: "Thanks",
+ validate: func(t *testing.T, prompt string) {
+ assert.Contains(t, prompt, "TOOL_CALL:")
+ assert.Contains(t, prompt, "TOOL_RESULT:")
+ assert.Contains(t, prompt, "list_emails")
+ assert.Contains(t, prompt, "You have no emails.")
+ },
+ },
+ {
+ name: "includes grant ID in system prompt",
+ setupConv: func(t *testing.T) *Conversation {
+ return &Conversation{ID: "conv_test", Messages: []Message{}}
+ },
+ newMessage: "Test",
+ validate: func(t *testing.T, prompt string) {
+ assert.Contains(t, prompt, grantID)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ conv := tt.setupConv(t)
+ prompt := builder.BuildPrompt(conv, tt.newMessage)
+
+ assert.NotEmpty(t, prompt)
+ if tt.validate != nil {
+ tt.validate(t, prompt)
+ }
+ })
+ }
+}
+
+func TestContextBuilder_NeedsCompaction(t *testing.T) {
+ agent := &Agent{Type: AgentClaude, Path: "/usr/bin/claude"}
+ store := setupMemoryStore(t)
+ builder := NewContextBuilder(agent, store, "test-grant")
+
+ tests := []struct {
+ name string
+ setupConv func(t *testing.T) *Conversation
+ wantCompact bool
+ description string
+ }{
+ {
+ name: "empty conversation",
+ setupConv: func(t *testing.T) *Conversation {
+ return &Conversation{
+ ID: "conv_test",
+ Messages: []Message{},
+ }
+ },
+ wantCompact: false,
+ description: "no messages should not need compaction",
+ },
+ {
+ name: "below threshold",
+ setupConv: func(t *testing.T) *Conversation {
+ messages := make([]Message, 20) // 10 user + 10 assistant = 20 total
+ for i := 0; i < 20; i++ {
+ role := "user"
+ if i%2 == 1 {
+ role = "assistant"
+ }
+ messages[i] = Message{Role: role, Content: "Message", Timestamp: time.Now()}
+ }
+ return &Conversation{ID: "conv_test", Messages: messages}
+ },
+ wantCompact: false,
+ description: "20 user+assistant messages should not need compaction (threshold is 30)",
+ },
+ {
+ name: "exactly at threshold",
+ setupConv: func(t *testing.T) *Conversation {
+ messages := make([]Message, compactionThreshold)
+ for i := 0; i < compactionThreshold; i++ {
+ role := "user"
+ if i%2 == 1 {
+ role = "assistant"
+ }
+ messages[i] = Message{Role: role, Content: "Message", Timestamp: time.Now()}
+ }
+ return &Conversation{ID: "conv_test", Messages: messages}
+ },
+ wantCompact: false,
+ description: "exactly at threshold should not need compaction",
+ },
+ {
+ name: "above threshold",
+ setupConv: func(t *testing.T) *Conversation {
+ messages := make([]Message, compactionThreshold+2)
+ for i := 0; i < compactionThreshold+2; i++ {
+ role := "user"
+ if i%2 == 1 {
+ role = "assistant"
+ }
+ messages[i] = Message{Role: role, Content: "Message", Timestamp: time.Now()}
+ }
+ return &Conversation{ID: "conv_test", Messages: messages}
+ },
+ wantCompact: true,
+ description: "above threshold should need compaction",
+ },
+ {
+ name: "many messages with tool calls",
+ setupConv: func(t *testing.T) *Conversation {
+ messages := make([]Message, 100)
+ userAssistantCount := 0
+ for i := 0; i < 100; i++ {
+ switch i % 4 {
+ case 0:
+ messages[i] = Message{Role: "user", Content: "Message", Timestamp: time.Now()}
+ userAssistantCount++
+ case 1:
+ messages[i] = Message{Role: "tool_call", Content: "{}", Timestamp: time.Now()}
+ case 2:
+ messages[i] = Message{Role: "tool_result", Content: "{}", Timestamp: time.Now()}
+ case 3:
+ messages[i] = Message{Role: "assistant", Content: "Message", Timestamp: time.Now()}
+ userAssistantCount++
+ }
+ }
+ // With this pattern, we have 50 user+assistant messages
+ return &Conversation{ID: "conv_test", Messages: messages}
+ },
+ wantCompact: true,
+ description: "50 user+assistant messages (with tool messages) should need compaction",
+ },
+ {
+ name: "only tool messages",
+ setupConv: func(t *testing.T) *Conversation {
+ messages := make([]Message, 50)
+ for i := 0; i < 50; i++ {
+ role := "tool_call"
+ if i%2 == 1 {
+ role = "tool_result"
+ }
+ messages[i] = Message{Role: role, Content: "{}", Timestamp: time.Now()}
+ }
+ return &Conversation{ID: "conv_test", Messages: messages}
+ },
+ wantCompact: false,
+ description: "only tool messages should not trigger compaction",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ conv := tt.setupConv(t)
+ result := builder.NeedsCompaction(conv)
+ assert.Equal(t, tt.wantCompact, result, tt.description)
+ })
+ }
+}
+
+func TestContextBuilder_findSplitIndex(t *testing.T) {
+ agent := &Agent{Type: AgentClaude, Path: "/usr/bin/claude"}
+ store := setupMemoryStore(t)
+ builder := NewContextBuilder(agent, store, "test-grant")
+
+ tests := []struct {
+ name string
+ setupConv func(t *testing.T) *Conversation
+ wantIndex int
+ description string
+ }{
+ {
+ name: "empty conversation",
+ setupConv: func(t *testing.T) *Conversation {
+ return &Conversation{ID: "conv_test", Messages: []Message{}}
+ },
+ wantIndex: 0,
+ description: "empty conversation should return 0",
+ },
+ {
+ name: "fewer messages than window",
+ setupConv: func(t *testing.T) *Conversation {
+ messages := make([]Message, 10)
+ for i := 0; i < 10; i++ {
+ role := "user"
+ if i%2 == 1 {
+ role = "assistant"
+ }
+ messages[i] = Message{Role: role, Content: "Message", Timestamp: time.Now()}
+ }
+ return &Conversation{ID: "conv_test", Messages: messages}
+ },
+ wantIndex: 0,
+ description: "fewer than compactionWindow messages should return 0",
+ },
+ {
+ name: "exactly compactionWindow messages",
+ setupConv: func(t *testing.T) *Conversation {
+ messages := make([]Message, compactionWindow)
+ for i := 0; i < compactionWindow; i++ {
+ role := "user"
+ if i%2 == 1 {
+ role = "assistant"
+ }
+ messages[i] = Message{Role: role, Content: "Message", Timestamp: time.Now()}
+ }
+ return &Conversation{ID: "conv_test", Messages: messages}
+ },
+ wantIndex: 0,
+ description: "exactly compactionWindow messages should return 0",
+ },
+ {
+ name: "more than compactionWindow messages",
+ setupConv: func(t *testing.T) *Conversation {
+ // Create 40 user+assistant messages
+ messages := make([]Message, 40)
+ for i := 0; i < 40; i++ {
+ role := "user"
+ if i%2 == 1 {
+ role = "assistant"
+ }
+ messages[i] = Message{Role: role, Content: "Message " + string(rune('A'+i)), Timestamp: time.Now()}
+ }
+ return &Conversation{ID: "conv_test", Messages: messages}
+ },
+ wantIndex: 40 - compactionWindow, // Should keep last 15 messages
+ description: "should split to keep last compactionWindow messages",
+ },
+ {
+ name: "messages with tool calls interspersed",
+ setupConv: func(t *testing.T) *Conversation {
+ // Create pattern: user, tool_call, tool_result, assistant (repeat)
+ messages := make([]Message, 60)
+ for i := 0; i < 60; i++ {
+ switch i % 4 {
+ case 0:
+ messages[i] = Message{Role: "user", Content: "U" + string(rune('A'+i/4)), Timestamp: time.Now()}
+ case 1:
+ messages[i] = Message{Role: "tool_call", Content: "{}", Timestamp: time.Now()}
+ case 2:
+ messages[i] = Message{Role: "tool_result", Content: "{}", Timestamp: time.Now()}
+ case 3:
+ messages[i] = Message{Role: "assistant", Content: "A" + string(rune('A'+i/4)), Timestamp: time.Now()}
+ }
+ }
+ // We have 30 user+assistant messages (15 pairs)
+ // Want to keep last compactionWindow (15), so split at index of first message to keep
+ return &Conversation{ID: "conv_test", Messages: messages}
+ },
+ wantIndex: 0, // 30 messages total, keep last 15, so split at first of last 15
+ description: "should find correct split with tool messages",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ conv := tt.setupConv(t)
+ index := builder.findSplitIndex(conv)
+
+ switch tt.name {
+ case "more than compactionWindow messages":
+ assert.Equal(t, 25, index, tt.description)
+ kept := conv.Messages[index:]
+ count := 0
+ for _, msg := range kept {
+ if msg.Role == "user" || msg.Role == "assistant" {
+ count++
+ }
+ }
+ assert.Equal(t, compactionWindow, count, "should keep exactly compactionWindow messages")
+ case "messages with tool calls interspersed":
+ kept := conv.Messages[index:]
+ count := 0
+ for _, msg := range kept {
+ if msg.Role == "user" || msg.Role == "assistant" {
+ count++
+ }
+ }
+ assert.GreaterOrEqual(t, count, compactionWindow-1, "should keep at least compactionWindow-1 messages")
+ default:
+ assert.Equal(t, tt.wantIndex, index, tt.description)
+ }
+ })
+ }
+}
+
+func TestContextBuilder_BuildPrompt_Structure(t *testing.T) {
+ agent := &Agent{Type: AgentClaude, Path: "/usr/bin/claude"}
+ store := setupMemoryStore(t)
+ builder := NewContextBuilder(agent, store, "test-grant")
+
+ conv := &Conversation{
+ ID: "conv_test",
+ Summary: "Previous discussion about emails",
+ Messages: []Message{
+ {Role: "user", Content: "Hello", Timestamp: time.Now()},
+ {Role: "assistant", Content: "Hi!", Timestamp: time.Now()},
+ },
+ }
+
+ prompt := builder.BuildPrompt(conv, "New message")
+
+ // Split prompt into sections
+ sections := strings.Split(prompt, "---")
+
+ t.Run("has correct number of sections", func(t *testing.T) {
+ assert.GreaterOrEqual(t, len(sections), 2, "should have at least 2 sections separated by ---")
+ })
+
+ t.Run("system prompt comes first", func(t *testing.T) {
+ assert.Contains(t, sections[0], "You are a helpful email and calendar assistant")
+ assert.Contains(t, sections[0], "Available tools:")
+ })
+
+ t.Run("summary comes after system prompt", func(t *testing.T) {
+ promptStr := prompt
+ summaryIdx := strings.Index(promptStr, "Previous Conversation Summary")
+ systemIdx := strings.Index(promptStr, "You are a helpful AI assistant")
+ assert.Greater(t, summaryIdx, systemIdx, "summary should come after system prompt")
+ })
+
+ t.Run("summary comes before conversation", func(t *testing.T) {
+ promptStr := prompt
+ // Look for the "## Conversation" header that comes after summary, not the one in system prompt
+ summaryIdx := strings.Index(promptStr, "Previous Conversation Summary")
+ // Find "## Conversation" that appears after the summary
+ conversationIdx := strings.Index(promptStr[summaryIdx:], "## Conversation")
+ if conversationIdx != -1 {
+ conversationIdx += summaryIdx // Adjust to absolute position
+ }
+
+ assert.NotEqual(t, -1, summaryIdx, "summary should be present")
+ assert.NotEqual(t, -1, conversationIdx, "conversation section should be present after summary")
+ assert.Greater(t, conversationIdx, summaryIdx, "conversation should come after summary")
+ })
+
+ t.Run("ends with new message and Assistant:", func(t *testing.T) {
+ assert.True(t, strings.HasSuffix(prompt, "User: New message\n\nAssistant: "),
+ "prompt should end with new message and 'Assistant:'")
+ })
+}
diff --git a/internal/chat/executor.go b/internal/chat/executor.go
new file mode 100644
index 0000000..c28882f
--- /dev/null
+++ b/internal/chat/executor.go
@@ -0,0 +1,387 @@
+package chat
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/nylas/cli/internal/domain"
+ "github.com/nylas/cli/internal/ports"
+)
+
+// ToolExecutor dispatches tool calls to the Nylas API.
+type ToolExecutor struct {
+ client ports.NylasClient
+ grantID string
+}
+
+// NewToolExecutor creates a new ToolExecutor.
+func NewToolExecutor(client ports.NylasClient, grantID string) *ToolExecutor {
+ return &ToolExecutor{client: client, grantID: grantID}
+}
+
+// Execute runs a tool call and returns the result.
+func (e *ToolExecutor) Execute(ctx context.Context, call ToolCall) ToolResult {
+ switch call.Name {
+ case "list_emails":
+ return e.listEmails(ctx, call.Args)
+ case "read_email":
+ return e.readEmail(ctx, call.Args)
+ case "search_emails":
+ return e.searchEmails(ctx, call.Args)
+ case "send_email":
+ return e.sendEmail(ctx, call.Args)
+ case "list_events":
+ return e.listEvents(ctx, call.Args)
+ case "create_event":
+ return e.createEvent(ctx, call.Args)
+ case "list_contacts":
+ return e.listContacts(ctx, call.Args)
+ case "list_folders":
+ return e.listFolders(ctx)
+ default:
+ return ToolResult{Name: call.Name, Error: fmt.Sprintf("unknown tool: %s", call.Name)}
+ }
+}
+
+func (e *ToolExecutor) listEmails(ctx context.Context, args map[string]any) ToolResult {
+ params := &domain.MessageQueryParams{Limit: 10}
+
+ if v, ok := args["limit"]; ok {
+ if f, ok := v.(float64); ok {
+ params.Limit = int(f)
+ }
+ }
+ if v, ok := args["subject"]; ok {
+ if s, ok := v.(string); ok {
+ params.Subject = s
+ }
+ }
+ if v, ok := args["from"]; ok {
+ if s, ok := v.(string); ok {
+ params.From = s
+ }
+ }
+ if v, ok := args["unread"]; ok {
+ if b, ok := v.(bool); ok {
+ params.Unread = &b
+ }
+ }
+
+ messages, err := e.client.GetMessagesWithParams(ctx, e.grantID, params)
+ if err != nil {
+ return ToolResult{Name: "list_emails", Error: err.Error()}
+ }
+
+ // Return simplified message list
+ type emailSummary struct {
+ ID string `json:"id"`
+ Subject string `json:"subject"`
+ From string `json:"from"`
+ Date string `json:"date"`
+ Unread bool `json:"unread"`
+ Snippet string `json:"snippet"`
+ }
+
+ var results []emailSummary
+ for _, m := range messages {
+ from := ""
+ if len(m.From) > 0 {
+ from = m.From[0].Email
+ if m.From[0].Name != "" {
+ from = m.From[0].Name + " <" + m.From[0].Email + ">"
+ }
+ }
+ results = append(results, emailSummary{
+ ID: m.ID,
+ Subject: m.Subject,
+ From: from,
+ Date: m.Date.Format(time.RFC3339),
+ Unread: m.Unread,
+ Snippet: m.Snippet,
+ })
+ }
+
+ return ToolResult{Name: "list_emails", Data: results}
+}
+
+func (e *ToolExecutor) readEmail(ctx context.Context, args map[string]any) ToolResult {
+ id, ok := args["id"].(string)
+ if !ok || id == "" {
+ return ToolResult{Name: "read_email", Error: "id parameter is required"}
+ }
+
+ msg, err := e.client.GetMessage(ctx, e.grantID, id)
+ if err != nil {
+ return ToolResult{Name: "read_email", Error: err.Error()}
+ }
+
+ type emailDetail struct {
+ ID string `json:"id"`
+ Subject string `json:"subject"`
+ From string `json:"from"`
+ To []string `json:"to"`
+ Date string `json:"date"`
+ Body string `json:"body"`
+ }
+
+ from := ""
+ if len(msg.From) > 0 {
+ from = msg.From[0].Email
+ if msg.From[0].Name != "" {
+ from = msg.From[0].Name + " <" + msg.From[0].Email + ">"
+ }
+ }
+
+ var to []string
+ for _, t := range msg.To {
+ to = append(to, t.Email)
+ }
+
+ body := msg.Body
+ if len(body) > 5000 {
+ body = body[:5000] + "\n... [truncated]"
+ }
+
+ return ToolResult{Name: "read_email", Data: emailDetail{
+ ID: msg.ID,
+ Subject: msg.Subject,
+ From: from,
+ To: to,
+ Date: msg.Date.Format(time.RFC3339),
+ Body: body,
+ }}
+}
+
+func (e *ToolExecutor) searchEmails(ctx context.Context, args map[string]any) ToolResult {
+ query, ok := args["query"].(string)
+ if !ok || query == "" {
+ return ToolResult{Name: "search_emails", Error: "query parameter is required"}
+ }
+
+ params := &domain.MessageQueryParams{
+ Limit: 10,
+ SearchQuery: query,
+ }
+
+ if v, ok := args["limit"]; ok {
+ if f, ok := v.(float64); ok {
+ params.Limit = int(f)
+ }
+ }
+
+ messages, err := e.client.GetMessagesWithParams(ctx, e.grantID, params)
+ if err != nil {
+ return ToolResult{Name: "search_emails", Error: err.Error()}
+ }
+
+ type emailSummary struct {
+ ID string `json:"id"`
+ Subject string `json:"subject"`
+ From string `json:"from"`
+ Date string `json:"date"`
+ Snippet string `json:"snippet"`
+ }
+
+ var results []emailSummary
+ for _, m := range messages {
+ from := ""
+ if len(m.From) > 0 {
+ from = m.From[0].Email
+ }
+ results = append(results, emailSummary{
+ ID: m.ID,
+ Subject: m.Subject,
+ From: from,
+ Date: m.Date.Format(time.RFC3339),
+ Snippet: m.Snippet,
+ })
+ }
+
+ return ToolResult{Name: "search_emails", Data: results}
+}
+
+func (e *ToolExecutor) sendEmail(ctx context.Context, args map[string]any) ToolResult {
+ to, _ := args["to"].(string)
+ subject, _ := args["subject"].(string)
+ body, _ := args["body"].(string)
+
+ if to == "" || subject == "" || body == "" {
+ return ToolResult{Name: "send_email", Error: "to, subject, and body are required"}
+ }
+
+ req := &domain.SendMessageRequest{
+ To: []domain.EmailParticipant{{Email: to}},
+ Subject: subject,
+ Body: body,
+ }
+
+ msg, err := e.client.SendMessage(ctx, e.grantID, req)
+ if err != nil {
+ return ToolResult{Name: "send_email", Error: err.Error()}
+ }
+
+ return ToolResult{Name: "send_email", Data: map[string]string{
+ "id": msg.ID,
+ "status": "sent",
+ }}
+}
+
+func (e *ToolExecutor) listEvents(ctx context.Context, args map[string]any) ToolResult {
+ calendarID := "primary"
+ if v, ok := args["calendar_id"].(string); ok && v != "" {
+ calendarID = v
+ }
+
+ params := &domain.EventQueryParams{Limit: 10}
+ if v, ok := args["limit"]; ok {
+ if f, ok := v.(float64); ok {
+ params.Limit = int(f)
+ }
+ }
+
+ events, err := e.client.GetEvents(ctx, e.grantID, calendarID, params)
+ if err != nil {
+ return ToolResult{Name: "list_events", Error: err.Error()}
+ }
+
+ type eventSummary struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Start string `json:"start"`
+ End string `json:"end"`
+ }
+
+ var results []eventSummary
+ for _, ev := range events {
+ start := ""
+ end := ""
+ if ev.When.StartTime > 0 {
+ start = time.Unix(ev.When.StartTime, 0).Format(time.RFC3339)
+ }
+ if ev.When.EndTime > 0 {
+ end = time.Unix(ev.When.EndTime, 0).Format(time.RFC3339)
+ }
+ results = append(results, eventSummary{
+ ID: ev.ID,
+ Title: ev.Title,
+ Start: start,
+ End: end,
+ })
+ }
+
+ return ToolResult{Name: "list_events", Data: results}
+}
+
+func (e *ToolExecutor) createEvent(ctx context.Context, args map[string]any) ToolResult {
+ title, _ := args["title"].(string)
+ startStr, _ := args["start_time"].(string)
+ endStr, _ := args["end_time"].(string)
+
+ if title == "" || startStr == "" || endStr == "" {
+ return ToolResult{Name: "create_event", Error: "title, start_time, and end_time are required"}
+ }
+
+ startTime, err := time.Parse(time.RFC3339, startStr)
+ if err != nil {
+ return ToolResult{Name: "create_event", Error: fmt.Sprintf("invalid start_time: %v", err)}
+ }
+ endTime, err := time.Parse(time.RFC3339, endStr)
+ if err != nil {
+ return ToolResult{Name: "create_event", Error: fmt.Sprintf("invalid end_time: %v", err)}
+ }
+
+ calendarID := "primary"
+ if v, ok := args["calendar_id"].(string); ok && v != "" {
+ calendarID = v
+ }
+
+ desc, _ := args["description"].(string)
+
+ req := &domain.CreateEventRequest{
+ Title: title,
+ When: domain.EventWhen{
+ StartTime: startTime.Unix(),
+ EndTime: endTime.Unix(),
+ },
+ Description: desc,
+ }
+
+ event, err := e.client.CreateEvent(ctx, e.grantID, calendarID, req)
+ if err != nil {
+ return ToolResult{Name: "create_event", Error: err.Error()}
+ }
+
+ return ToolResult{Name: "create_event", Data: map[string]string{
+ "id": event.ID,
+ "title": event.Title,
+ }}
+}
+
+func (e *ToolExecutor) listContacts(ctx context.Context, args map[string]any) ToolResult {
+ params := &domain.ContactQueryParams{Limit: 10}
+
+ if v, ok := args["limit"]; ok {
+ if f, ok := v.(float64); ok {
+ params.Limit = int(f)
+ }
+ }
+ if v, ok := args["query"].(string); ok && v != "" {
+ params.Email = v
+ }
+
+ contacts, err := e.client.GetContacts(ctx, e.grantID, params)
+ if err != nil {
+ return ToolResult{Name: "list_contacts", Error: err.Error()}
+ }
+
+ type contactSummary struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+ }
+
+ var results []contactSummary
+ for _, c := range contacts {
+ email := ""
+ if len(c.Emails) > 0 {
+ email = c.Emails[0].Email
+ }
+ name := ""
+ if c.GivenName != "" {
+ name = c.GivenName
+ if c.Surname != "" {
+ name += " " + c.Surname
+ }
+ }
+ results = append(results, contactSummary{
+ ID: c.ID,
+ Name: name,
+ Email: email,
+ })
+ }
+
+ return ToolResult{Name: "list_contacts", Data: results}
+}
+
+func (e *ToolExecutor) listFolders(ctx context.Context) ToolResult {
+ folders, err := e.client.GetFolders(ctx, e.grantID)
+ if err != nil {
+ return ToolResult{Name: "list_folders", Error: err.Error()}
+ }
+
+ type folderSummary struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ }
+
+ var results []folderSummary
+ for _, f := range folders {
+ results = append(results, folderSummary{
+ ID: f.ID,
+ Name: f.Name,
+ })
+ }
+
+ return ToolResult{Name: "list_folders", Data: results}
+}
diff --git a/internal/chat/handlers.go b/internal/chat/handlers.go
new file mode 100644
index 0000000..b0085b2
--- /dev/null
+++ b/internal/chat/handlers.go
@@ -0,0 +1,273 @@
+package chat
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/nylas/cli/internal/httputil"
+)
+
+// chatRequest is the request body for POST /api/chat.
+type chatRequest struct {
+ Message string `json:"message"`
+ ConversationID string `json:"conversation_id"`
+ Agent string `json:"agent,omitempty"` // optional: switch agent for this message
+}
+
+// maxToolIterations is the maximum number of tool call rounds per message.
+const maxToolIterations = 5
+
+// handleChat processes a chat message via SSE streaming.
+// POST /api/chat
+func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ var req chatRequest
+ if err := httputil.DecodeJSON(w, r, &req); err != nil {
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.Message == "" {
+ http.Error(w, "message is required", http.StatusBadRequest)
+ return
+ }
+
+ // Switch agent if requested
+ if req.Agent != "" {
+ if !s.SetAgent(AgentType(req.Agent)) {
+ http.Error(w, "unknown agent: "+req.Agent, http.StatusBadRequest)
+ return
+ }
+ }
+
+ agent := s.ActiveAgent()
+
+ // Load or create conversation
+ var conv *Conversation
+ var err error
+
+ if req.ConversationID != "" {
+ conv, err = s.memory.Get(req.ConversationID)
+ if err != nil {
+ http.Error(w, "Conversation not found", http.StatusNotFound)
+ return
+ }
+ } else {
+ conv, err = s.memory.Create(string(agent.Type))
+ if err != nil {
+ http.Error(w, "Failed to create conversation", http.StatusInternalServerError)
+ return
+ }
+ }
+
+ // Set SSE headers
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ http.Error(w, "Streaming not supported", http.StatusInternalServerError)
+ return
+ }
+
+ // Save user message
+ _ = s.memory.AddMessage(conv.ID, Message{Role: "user", Content: req.Message})
+
+ // Reload conversation with the new message
+ conv, _ = s.memory.Get(conv.ID)
+
+ // Check if compaction needed
+ if s.context.NeedsCompaction(conv) {
+ ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
+ _ = s.context.Compact(ctx, conv)
+ cancel()
+ conv, _ = s.memory.Get(conv.ID) // reload after compaction
+ }
+
+ // Send thinking event
+ sendSSE(w, flusher, "thinking", map[string]string{"agent": string(agent.Type)})
+
+ // Build prompt and run agent loop
+ ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
+ defer cancel()
+
+ prompt := s.context.BuildPrompt(conv, req.Message)
+ var finalResponse string
+
+ for i := range maxToolIterations {
+ _ = i
+
+ response, err := agent.Run(ctx, prompt)
+ if err != nil {
+ sendSSE(w, flusher, "error", map[string]string{"error": err.Error()})
+ return
+ }
+
+ // Parse tool calls from response
+ toolCalls, textResponse := ParseToolCalls(response)
+
+ if len(toolCalls) == 0 {
+ // No tool calls - this is the final response
+ finalResponse = textResponse
+ break
+ }
+
+ // Execute tool calls and collect results
+ for _, call := range toolCalls {
+ sendSSE(w, flusher, "tool_call", map[string]any{
+ "name": call.Name,
+ "args": call.Args,
+ })
+
+ result := s.executor.Execute(ctx, call)
+
+ sendSSE(w, flusher, "tool_result", map[string]any{
+ "name": result.Name,
+ "data": result.Data,
+ "error": result.Error,
+ })
+
+ // Save tool interactions
+ callJSON, _ := json.Marshal(call)
+ _ = s.memory.AddMessage(conv.ID, Message{
+ Role: "tool_call",
+ Name: call.Name,
+ Content: string(callJSON),
+ })
+
+ resultJSON, _ := json.Marshal(result)
+ _ = s.memory.AddMessage(conv.ID, Message{
+ Role: "tool_result",
+ Name: result.Name,
+ Content: string(resultJSON),
+ })
+
+ // Append tool result to prompt (no trailing "Assistant:" yet)
+ prompt += "\n" + FormatToolResult(result)
+ }
+
+ // Add single "Assistant:" after all tool results for next iteration
+ prompt += "\n\nNow provide your final answer to the user based on the tool results above.\n\nAssistant: "
+ }
+
+ if finalResponse == "" {
+ finalResponse = "I wasn't able to generate a response. Please try again."
+ }
+
+ // Save assistant response
+ _ = s.memory.AddMessage(conv.ID, Message{Role: "assistant", Content: finalResponse})
+
+ // Send the message
+ sendSSE(w, flusher, "message", map[string]string{"content": finalResponse})
+
+ // Auto-generate title after first exchange
+ if conv.Title == "New conversation" {
+ go s.generateTitle(conv.ID, req.Message, finalResponse)
+ }
+
+ // Send done event
+ sendSSE(w, flusher, "done", map[string]string{
+ "conversation_id": conv.ID,
+ "title": conv.Title,
+ })
+}
+
+// generateTitle asks the agent to generate a short title for the conversation.
+func (s *Server) generateTitle(convID, userMsg, assistantMsg string) {
+ agent := s.ActiveAgent()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ prompt := fmt.Sprintf(
+ "Generate a very short title (3-6 words) for this conversation. "+
+ "Reply with ONLY the title, nothing else.\n\nUser: %s\nAssistant: %s",
+ userMsg, assistantMsg,
+ )
+
+ title, err := agent.Run(ctx, prompt)
+ if err != nil || title == "" {
+ return
+ }
+
+ // Clean up the title
+ if len(title) > 60 {
+ title = title[:60]
+ }
+
+ _ = s.memory.UpdateTitle(convID, title)
+}
+
+// handleConfig returns or updates agent configuration.
+// GET /api/config — returns current agent and available agents
+// PUT /api/config — switches the active agent
+func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ s.getConfig(w)
+ case http.MethodPut:
+ s.switchAgent(w, r)
+ default:
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+func (s *Server) getConfig(w http.ResponseWriter) {
+ agent := s.ActiveAgent()
+
+ available := make([]string, len(s.agents))
+ for i, a := range s.agents {
+ available[i] = string(a.Type)
+ }
+
+ httputil.WriteJSON(w, http.StatusOK, map[string]any{
+ "agent": string(agent.Type),
+ "available": available,
+ })
+}
+
+func (s *Server) switchAgent(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Agent string `json:"agent"`
+ }
+ if err := httputil.DecodeJSON(w, r, &req); err != nil {
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.Agent == "" {
+ http.Error(w, "agent is required", http.StatusBadRequest)
+ return
+ }
+
+ if !s.SetAgent(AgentType(req.Agent)) {
+ http.Error(w, "unknown agent: "+req.Agent, http.StatusBadRequest)
+ return
+ }
+
+ s.getConfig(w)
+}
+
+// handleHealth returns server health status.
+// GET /api/health
+func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
+ httputil.WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"})
+}
+
+// sendSSE writes a Server-Sent Event to the response.
+func sendSSE(w http.ResponseWriter, flusher http.Flusher, event string, data any) {
+ jsonData, err := json.Marshal(data)
+ if err != nil {
+ return
+ }
+ _, _ = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, jsonData)
+ flusher.Flush()
+}
diff --git a/internal/chat/handlers_conv.go b/internal/chat/handlers_conv.go
new file mode 100644
index 0000000..1ab6053
--- /dev/null
+++ b/internal/chat/handlers_conv.go
@@ -0,0 +1,88 @@
+package chat
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/nylas/cli/internal/httputil"
+)
+
+// handleConversations handles listing and creating conversations.
+// GET /api/conversations — list all conversations
+// POST /api/conversations — create new conversation
+func (s *Server) handleConversations(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ s.listConversations(w, r)
+ case http.MethodPost:
+ s.createConversation(w, r)
+ default:
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+// handleConversationByID handles operations on a specific conversation.
+// GET /api/conversations/{id} — get conversation
+// DELETE /api/conversations/{id} — delete conversation
+func (s *Server) handleConversationByID(w http.ResponseWriter, r *http.Request) {
+ id := strings.TrimPrefix(r.URL.Path, "/api/conversations/")
+ if id == "" {
+ http.Error(w, "conversation ID required", http.StatusBadRequest)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ s.getConversation(w, id)
+ case http.MethodDelete:
+ s.deleteConversation(w, id)
+ default:
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+func (s *Server) listConversations(w http.ResponseWriter, _ *http.Request) {
+ summaries, err := s.memory.List()
+ if err != nil {
+ http.Error(w, "Failed to list conversations", http.StatusInternalServerError)
+ return
+ }
+
+ if summaries == nil {
+ summaries = []ConversationSummary{}
+ }
+
+ httputil.WriteJSON(w, http.StatusOK, summaries)
+}
+
+func (s *Server) createConversation(w http.ResponseWriter, _ *http.Request) {
+ conv, err := s.memory.Create(string(s.agent.Type))
+ if err != nil {
+ http.Error(w, "Failed to create conversation", http.StatusInternalServerError)
+ return
+ }
+
+ httputil.WriteJSON(w, http.StatusCreated, map[string]string{
+ "id": conv.ID,
+ "title": conv.Title,
+ })
+}
+
+func (s *Server) getConversation(w http.ResponseWriter, id string) {
+ conv, err := s.memory.Get(id)
+ if err != nil {
+ http.Error(w, "Conversation not found", http.StatusNotFound)
+ return
+ }
+
+ httputil.WriteJSON(w, http.StatusOK, conv)
+}
+
+func (s *Server) deleteConversation(w http.ResponseWriter, id string) {
+ if err := s.memory.Delete(id); err != nil {
+ http.Error(w, "Failed to delete conversation", http.StatusNotFound)
+ return
+ }
+
+ httputil.WriteJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
+}
diff --git a/internal/chat/memory.go b/internal/chat/memory.go
new file mode 100644
index 0000000..c1fb0e5
--- /dev/null
+++ b/internal/chat/memory.go
@@ -0,0 +1,244 @@
+package chat
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+)
+
+// Conversation represents a chat conversation with full message history.
+type Conversation struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Agent string `json:"agent"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ Messages []Message `json:"messages"`
+ Summary string `json:"summary,omitempty"`
+ MsgCount int `json:"message_count"`
+ CompactedAt time.Time `json:"compacted_at,omitempty"`
+}
+
+// Message represents a single message in a conversation.
+type Message struct {
+ Role string `json:"role"` // user, assistant, tool_call, tool_result
+ Content string `json:"content"`
+ Name string `json:"name,omitempty"` // tool name for tool_call/tool_result
+ Timestamp time.Time `json:"timestamp"`
+}
+
+// ConversationSummary is a lightweight view for listing conversations.
+type ConversationSummary struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Agent string `json:"agent"`
+ UpdatedAt time.Time `json:"updated_at"`
+ Preview string `json:"preview"`
+ MsgCount int `json:"message_count"`
+}
+
+// MemoryStore manages conversation persistence as JSON files on disk.
+type MemoryStore struct {
+ basePath string
+ mu sync.RWMutex
+}
+
+// NewMemoryStore creates a new MemoryStore at the given base path.
+func NewMemoryStore(basePath string) (*MemoryStore, error) {
+ if err := os.MkdirAll(basePath, 0750); err != nil {
+ return nil, fmt.Errorf("create memory directory: %w", err)
+ }
+ return &MemoryStore{basePath: basePath}, nil
+}
+
+// List returns summaries of all conversations, sorted by most recent first.
+func (m *MemoryStore) List() ([]ConversationSummary, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+
+ entries, err := os.ReadDir(m.basePath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("read conversations dir: %w", err)
+ }
+
+ var summaries []ConversationSummary
+ for _, entry := range entries {
+ if !strings.HasSuffix(entry.Name(), ".json") {
+ continue
+ }
+
+ conv, err := m.readFile(filepath.Join(m.basePath, entry.Name()))
+ if err != nil {
+ continue // skip corrupt files
+ }
+
+ preview := ""
+ for i := len(conv.Messages) - 1; i >= 0; i-- {
+ if conv.Messages[i].Role == "assistant" || conv.Messages[i].Role == "user" {
+ preview = conv.Messages[i].Content
+ if len(preview) > 100 {
+ preview = preview[:100] + "..."
+ }
+ break
+ }
+ }
+
+ summaries = append(summaries, ConversationSummary{
+ ID: conv.ID,
+ Title: conv.Title,
+ Agent: conv.Agent,
+ UpdatedAt: conv.UpdatedAt,
+ Preview: preview,
+ MsgCount: conv.MsgCount,
+ })
+ }
+
+ sort.Slice(summaries, func(i, j int) bool {
+ return summaries[i].UpdatedAt.After(summaries[j].UpdatedAt)
+ })
+
+ return summaries, nil
+}
+
+// Get retrieves a conversation by ID.
+func (m *MemoryStore) Get(id string) (*Conversation, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return m.readFile(m.filePath(id))
+}
+
+// Create creates a new conversation for the given agent.
+func (m *MemoryStore) Create(agent string) (*Conversation, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ id, err := generateID()
+ if err != nil {
+ return nil, fmt.Errorf("generate conversation ID: %w", err)
+ }
+
+ conv := &Conversation{
+ ID: id,
+ Title: "New conversation",
+ Agent: agent,
+ CreatedAt: time.Now().UTC(),
+ UpdatedAt: time.Now().UTC(),
+ Messages: []Message{},
+ }
+
+ if err := m.writeFile(conv); err != nil {
+ return nil, err
+ }
+ return conv, nil
+}
+
+// Delete removes a conversation by ID.
+func (m *MemoryStore) Delete(id string) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ path := m.filePath(id)
+ if err := os.Remove(path); err != nil {
+ if os.IsNotExist(err) {
+ return fmt.Errorf("conversation not found: %s", id)
+ }
+ return fmt.Errorf("delete conversation: %w", err)
+ }
+ return nil
+}
+
+// AddMessage appends a message to a conversation.
+func (m *MemoryStore) AddMessage(id string, msg Message) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ conv, err := m.readFile(m.filePath(id))
+ if err != nil {
+ return err
+ }
+
+ msg.Timestamp = time.Now().UTC()
+ conv.Messages = append(conv.Messages, msg)
+ conv.MsgCount = len(conv.Messages)
+ conv.UpdatedAt = time.Now().UTC()
+
+ return m.writeFile(conv)
+}
+
+// UpdateTitle updates the title of a conversation.
+func (m *MemoryStore) UpdateTitle(id string, title string) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ conv, err := m.readFile(m.filePath(id))
+ if err != nil {
+ return err
+ }
+
+ conv.Title = title
+ conv.UpdatedAt = time.Now().UTC()
+ return m.writeFile(conv)
+}
+
+// UpdateSummary updates the summary and trims compacted messages.
+func (m *MemoryStore) UpdateSummary(id string, summary string, keepFrom int) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ conv, err := m.readFile(m.filePath(id))
+ if err != nil {
+ return err
+ }
+
+ conv.Summary = summary
+ conv.CompactedAt = time.Now().UTC()
+ if keepFrom > 0 && keepFrom < len(conv.Messages) {
+ conv.Messages = conv.Messages[keepFrom:]
+ }
+ conv.UpdatedAt = time.Now().UTC()
+
+ return m.writeFile(conv)
+}
+
+func (m *MemoryStore) filePath(id string) string {
+ return filepath.Join(m.basePath, id+".json")
+}
+
+func (m *MemoryStore) readFile(path string) (*Conversation, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("read conversation: %w", err)
+ }
+
+ var conv Conversation
+ if err := json.Unmarshal(data, &conv); err != nil {
+ return nil, fmt.Errorf("parse conversation: %w", err)
+ }
+ return &conv, nil
+}
+
+func (m *MemoryStore) writeFile(conv *Conversation) error {
+ data, err := json.MarshalIndent(conv, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal conversation: %w", err)
+ }
+ return os.WriteFile(m.filePath(conv.ID), data, 0600)
+}
+
+func generateID() (string, error) {
+ b := make([]byte, 8)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return "conv_" + hex.EncodeToString(b), nil
+}
diff --git a/internal/chat/memory_test.go b/internal/chat/memory_test.go
new file mode 100644
index 0000000..407fa67
--- /dev/null
+++ b/internal/chat/memory_test.go
@@ -0,0 +1,513 @@
+package chat
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewMemoryStore(t *testing.T) {
+ tests := []struct {
+ name string
+ setup func(t *testing.T) string
+ wantError bool
+ }{
+ {
+ name: "creates directory if not exists",
+ setup: func(t *testing.T) string {
+ return filepath.Join(t.TempDir(), "conversations")
+ },
+ wantError: false,
+ },
+ {
+ name: "works with existing directory",
+ setup: func(t *testing.T) string {
+ dir := filepath.Join(t.TempDir(), "existing")
+ require.NoError(t, os.MkdirAll(dir, 0750))
+ return dir
+ },
+ wantError: false,
+ },
+ {
+ name: "creates nested directories",
+ setup: func(t *testing.T) string {
+ return filepath.Join(t.TempDir(), "level1", "level2", "conversations")
+ },
+ wantError: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ path := tt.setup(t)
+ store, err := NewMemoryStore(path)
+
+ if tt.wantError {
+ assert.Error(t, err)
+ assert.Nil(t, store)
+ } else {
+ require.NoError(t, err)
+ require.NotNil(t, store)
+ assert.Equal(t, path, store.basePath)
+
+ // Verify directory was created
+ info, err := os.Stat(path)
+ require.NoError(t, err)
+ assert.True(t, info.IsDir())
+ }
+ })
+ }
+}
+
+func TestMemoryStore_Create(t *testing.T) {
+ store := setupMemoryStore(t)
+
+ tests := []struct {
+ name string
+ agent string
+ wantError bool
+ }{
+ {
+ name: "creates conversation with claude agent",
+ agent: "claude",
+ wantError: false,
+ },
+ {
+ name: "creates conversation with ollama agent",
+ agent: "ollama",
+ wantError: false,
+ },
+ {
+ name: "creates conversation with empty agent",
+ agent: "",
+ wantError: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ conv, err := store.Create(tt.agent)
+
+ if tt.wantError {
+ assert.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ require.NotNil(t, conv)
+
+ // Verify fields
+ assert.NotEmpty(t, conv.ID, "ID should be generated")
+ assert.True(t, len(conv.ID) > len("conv_"), "ID should have conv_ prefix and random suffix")
+ assert.Equal(t, "New conversation", conv.Title)
+ assert.Equal(t, tt.agent, conv.Agent)
+ assert.False(t, conv.CreatedAt.IsZero(), "CreatedAt should be set")
+ assert.False(t, conv.UpdatedAt.IsZero(), "UpdatedAt should be set")
+ assert.Empty(t, conv.Messages, "Messages should be empty")
+ assert.Equal(t, 0, conv.MsgCount)
+ assert.Empty(t, conv.Summary)
+
+ // Verify file was created
+ filePath := filepath.Join(store.basePath, conv.ID+".json")
+ _, err := os.Stat(filePath)
+ require.NoError(t, err, "conversation file should exist")
+ }
+ })
+ }
+}
+
+func TestMemoryStore_Get(t *testing.T) {
+ store := setupMemoryStore(t)
+
+ // Create a conversation
+ created, err := store.Create("claude")
+ require.NoError(t, err)
+
+ tests := []struct {
+ name string
+ id string
+ wantError bool
+ }{
+ {
+ name: "retrieves existing conversation",
+ id: created.ID,
+ wantError: false,
+ },
+ {
+ name: "returns error for non-existent conversation",
+ id: "conv_nonexistent",
+ wantError: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ conv, err := store.Get(tt.id)
+
+ if tt.wantError {
+ assert.Error(t, err)
+ assert.Nil(t, conv)
+ } else {
+ require.NoError(t, err)
+ require.NotNil(t, conv)
+ assert.Equal(t, created.ID, conv.ID)
+ assert.Equal(t, created.Title, conv.Title)
+ assert.Equal(t, created.Agent, conv.Agent)
+ }
+ })
+ }
+}
+
+func TestMemoryStore_List(t *testing.T) {
+ store := setupMemoryStore(t)
+
+ t.Run("returns empty list when no conversations", func(t *testing.T) {
+ summaries, err := store.List()
+ require.NoError(t, err)
+ assert.Empty(t, summaries)
+ })
+
+ t.Run("lists conversations sorted by updated_at", func(t *testing.T) {
+ // Create multiple conversations with different update times
+ conv1, err := store.Create("claude")
+ require.NoError(t, err)
+ time.Sleep(10 * time.Millisecond)
+
+ conv2, err := store.Create("ollama")
+ require.NoError(t, err)
+ time.Sleep(10 * time.Millisecond)
+
+ conv3, err := store.Create("codex")
+ require.NoError(t, err)
+
+ // Update conv1 to make it most recent
+ time.Sleep(10 * time.Millisecond)
+ err = store.AddMessage(conv1.ID, Message{
+ Role: "user",
+ Content: "Latest message",
+ })
+ require.NoError(t, err)
+
+ // List and verify order (most recent first)
+ summaries, err := store.List()
+ require.NoError(t, err)
+ require.Len(t, summaries, 3)
+
+ // conv1 should be first (most recently updated)
+ assert.Equal(t, conv1.ID, summaries[0].ID)
+ assert.Equal(t, "claude", summaries[0].Agent)
+
+ // conv3 should be second
+ assert.Equal(t, conv3.ID, summaries[1].ID)
+
+ // conv2 should be last
+ assert.Equal(t, conv2.ID, summaries[2].ID)
+ })
+
+ t.Run("includes preview from last user or assistant message", func(t *testing.T) {
+ store := setupMemoryStore(t)
+ conv, err := store.Create("claude")
+ require.NoError(t, err)
+
+ // Add messages
+ err = store.AddMessage(conv.ID, Message{Role: "user", Content: "Hello"})
+ require.NoError(t, err)
+ err = store.AddMessage(conv.ID, Message{Role: "assistant", Content: "Hi there! How can I help you today?"})
+ require.NoError(t, err)
+
+ summaries, err := store.List()
+ require.NoError(t, err)
+ require.Len(t, summaries, 1)
+
+ assert.Equal(t, "Hi there! How can I help you today?", summaries[0].Preview)
+ assert.Equal(t, 2, summaries[0].MsgCount)
+ })
+
+ t.Run("truncates long preview to 100 characters", func(t *testing.T) {
+ store := setupMemoryStore(t)
+ conv, err := store.Create("claude")
+ require.NoError(t, err)
+
+ longMessage := "This is a very long message that should be truncated because it exceeds the maximum preview length of one hundred characters"
+ err = store.AddMessage(conv.ID, Message{Role: "user", Content: longMessage})
+ require.NoError(t, err)
+
+ summaries, err := store.List()
+ require.NoError(t, err)
+ require.Len(t, summaries, 1)
+
+ assert.Equal(t, 103, len(summaries[0].Preview)) // 100 chars + "..."
+ assert.True(t, len(summaries[0].Preview) <= 103)
+ assert.Contains(t, summaries[0].Preview, "...")
+ })
+}
+
+func TestMemoryStore_Delete(t *testing.T) {
+ store := setupMemoryStore(t)
+
+ // Create a conversation
+ conv, err := store.Create("claude")
+ require.NoError(t, err)
+
+ t.Run("deletes existing conversation", func(t *testing.T) {
+ err := store.Delete(conv.ID)
+ require.NoError(t, err)
+
+ // Verify file was deleted
+ filePath := filepath.Join(store.basePath, conv.ID+".json")
+ _, err = os.Stat(filePath)
+ assert.True(t, os.IsNotExist(err), "file should not exist after deletion")
+
+ // Verify Get returns error
+ _, err = store.Get(conv.ID)
+ assert.Error(t, err)
+ })
+
+ t.Run("returns error for non-existent conversation", func(t *testing.T) {
+ err := store.Delete("conv_nonexistent")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "conversation not found")
+ })
+}
+
+func TestMemoryStore_AddMessage(t *testing.T) {
+ store := setupMemoryStore(t)
+ conv, err := store.Create("claude")
+ require.NoError(t, err)
+
+ tests := []struct {
+ name string
+ message Message
+ }{
+ {
+ name: "adds user message",
+ message: Message{
+ Role: "user",
+ Content: "Hello, assistant!",
+ },
+ },
+ {
+ name: "adds assistant message",
+ message: Message{
+ Role: "assistant",
+ Content: "Hello! How can I help?",
+ },
+ },
+ {
+ name: "adds tool_call message",
+ message: Message{
+ Role: "tool_call",
+ Content: `{"name":"list_emails","args":{}}`,
+ Name: "list_emails",
+ },
+ },
+ {
+ name: "adds tool_result message",
+ message: Message{
+ Role: "tool_result",
+ Content: `{"name":"list_emails","data":[]}`,
+ Name: "list_emails",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ beforeUpdate := conv.UpdatedAt
+ time.Sleep(10 * time.Millisecond) // Ensure time difference
+
+ err := store.AddMessage(conv.ID, tt.message)
+ require.NoError(t, err)
+
+ // Retrieve and verify
+ updated, err := store.Get(conv.ID)
+ require.NoError(t, err)
+
+ assert.Greater(t, len(updated.Messages), 0)
+ lastMsg := updated.Messages[len(updated.Messages)-1]
+ assert.Equal(t, tt.message.Role, lastMsg.Role)
+ assert.Equal(t, tt.message.Content, lastMsg.Content)
+ assert.Equal(t, tt.message.Name, lastMsg.Name)
+ assert.False(t, lastMsg.Timestamp.IsZero(), "timestamp should be set")
+ assert.Equal(t, len(updated.Messages), updated.MsgCount)
+ assert.True(t, updated.UpdatedAt.After(beforeUpdate), "UpdatedAt should be updated")
+ })
+ }
+
+ t.Run("returns error for non-existent conversation", func(t *testing.T) {
+ err := store.AddMessage("conv_nonexistent", Message{
+ Role: "user",
+ Content: "Test",
+ })
+ assert.Error(t, err)
+ })
+}
+
+func TestMemoryStore_UpdateTitle(t *testing.T) {
+ store := setupMemoryStore(t)
+ conv, err := store.Create("claude")
+ require.NoError(t, err)
+
+ tests := []struct {
+ name string
+ newTitle string
+ }{
+ {
+ name: "updates title",
+ newTitle: "Email Discussion",
+ },
+ {
+ name: "updates to empty title",
+ newTitle: "",
+ },
+ {
+ name: "updates to long title",
+ newTitle: "A very long title that should still be stored correctly without any truncation issues",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ beforeUpdate := conv.UpdatedAt
+ time.Sleep(10 * time.Millisecond)
+
+ err := store.UpdateTitle(conv.ID, tt.newTitle)
+ require.NoError(t, err)
+
+ // Verify update
+ updated, err := store.Get(conv.ID)
+ require.NoError(t, err)
+ assert.Equal(t, tt.newTitle, updated.Title)
+ assert.True(t, updated.UpdatedAt.After(beforeUpdate), "UpdatedAt should be updated")
+ })
+ }
+
+ t.Run("returns error for non-existent conversation", func(t *testing.T) {
+ err := store.UpdateTitle("conv_nonexistent", "New Title")
+ assert.Error(t, err)
+ })
+}
+
+func TestMemoryStore_UpdateSummary(t *testing.T) {
+ store := setupMemoryStore(t)
+ conv, err := store.Create("claude")
+ require.NoError(t, err)
+
+ // Add some messages
+ for i := 0; i < 10; i++ {
+ err := store.AddMessage(conv.ID, Message{
+ Role: "user",
+ Content: "Message " + string(rune('A'+i)),
+ })
+ require.NoError(t, err)
+ }
+
+ tests := []struct {
+ name string
+ summary string
+ keepFrom int
+ }{
+ {
+ name: "updates summary without trimming",
+ summary: "First summary",
+ keepFrom: 0,
+ },
+ {
+ name: "updates summary and trims messages",
+ summary: "Summary after trimming",
+ keepFrom: 5,
+ },
+ {
+ name: "updates summary and keeps all messages",
+ summary: "Keep all",
+ keepFrom: -1,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ store := setupMemoryStore(t)
+ conv, err := store.Create("claude")
+ require.NoError(t, err)
+
+ // Add messages
+ for i := 0; i < 10; i++ {
+ err := store.AddMessage(conv.ID, Message{
+ Role: "user",
+ Content: "Message " + string(rune('A'+i)),
+ })
+ require.NoError(t, err)
+ }
+
+ beforeUpdate, err := store.Get(conv.ID)
+ require.NoError(t, err)
+ time.Sleep(10 * time.Millisecond)
+
+ err = store.UpdateSummary(conv.ID, tt.summary, tt.keepFrom)
+ require.NoError(t, err)
+
+ // Verify update
+ updated, err := store.Get(conv.ID)
+ require.NoError(t, err)
+ assert.Equal(t, tt.summary, updated.Summary)
+ assert.False(t, updated.CompactedAt.IsZero(), "CompactedAt should be set")
+ assert.True(t, updated.UpdatedAt.After(beforeUpdate.UpdatedAt), "UpdatedAt should be updated")
+
+ // Verify message trimming
+ if tt.keepFrom > 0 && tt.keepFrom < 10 {
+ assert.Equal(t, 10-tt.keepFrom, len(updated.Messages), "messages should be trimmed")
+ // First remaining message should be at keepFrom index
+ assert.Equal(t, "Message "+string(rune('A'+tt.keepFrom)), updated.Messages[0].Content)
+ } else if tt.keepFrom <= 0 {
+ assert.Equal(t, 10, len(updated.Messages), "all messages should be kept")
+ }
+ })
+ }
+
+ t.Run("returns error for non-existent conversation", func(t *testing.T) {
+ err := store.UpdateSummary("conv_nonexistent", "Summary", 0)
+ assert.Error(t, err)
+ })
+}
+
+func TestMemoryStore_ConcurrentAccess(t *testing.T) {
+ store := setupMemoryStore(t)
+ conv, err := store.Create("claude")
+ require.NoError(t, err)
+
+ // Test concurrent message additions
+ t.Run("concurrent message additions", func(t *testing.T) {
+ done := make(chan bool)
+ for i := 0; i < 5; i++ {
+ go func(index int) {
+ err := store.AddMessage(conv.ID, Message{
+ Role: "user",
+ Content: "Concurrent message",
+ })
+ assert.NoError(t, err)
+ done <- true
+ }(i)
+ }
+
+ // Wait for all goroutines
+ for i := 0; i < 5; i++ {
+ <-done
+ }
+
+ // Verify all messages were added
+ updated, err := store.Get(conv.ID)
+ require.NoError(t, err)
+ assert.Equal(t, 5, len(updated.Messages))
+ })
+}
+
+// Helper function to set up a memory store for testing
+func setupMemoryStore(t *testing.T) *MemoryStore {
+ t.Helper()
+ dir := t.TempDir()
+ store, err := NewMemoryStore(dir)
+ require.NoError(t, err)
+ return store
+}
diff --git a/internal/chat/prompt.go b/internal/chat/prompt.go
new file mode 100644
index 0000000..576c4a8
--- /dev/null
+++ b/internal/chat/prompt.go
@@ -0,0 +1,46 @@
+package chat
+
+import "strings"
+
+// BuildSystemPrompt constructs the system prompt for the AI agent.
+// It includes identity, available tools, and the text-based tool protocol.
+func BuildSystemPrompt(grantID string, agentType AgentType) string {
+ var sb strings.Builder
+
+ sb.WriteString("You are a helpful email and calendar assistant powered by the Nylas API.\n")
+ sb.WriteString("You help users manage their emails, calendar events, and contacts.\n\n")
+
+ sb.WriteString("Grant ID: " + grantID + "\n\n")
+
+ // Tool protocol instructions
+ sb.WriteString("## Tool Usage\n\n")
+ sb.WriteString("When you need to access the user's email, calendar, or contacts, use the tools below.\n")
+ sb.WriteString("To call a tool, output EXACTLY this format on its own line:\n\n")
+ sb.WriteString("TOOL_CALL: {\"name\": \"tool_name\", \"args\": {\"param\": \"value\"}}\n\n")
+ sb.WriteString("IMPORTANT RULES:\n")
+ sb.WriteString("1. When you output a TOOL_CALL, output ONLY the TOOL_CALL line and nothing else.\n")
+ sb.WriteString(" Do NOT include any other text before or after the TOOL_CALL line.\n")
+ sb.WriteString("2. After you receive tool results (TOOL_RESULT), use the data to answer the user.\n")
+ sb.WriteString(" Summarize and format the results clearly — do NOT make another tool call\n")
+ sb.WriteString(" unless you need different or additional data.\n")
+ sb.WriteString("3. Only make one TOOL_CALL per response. Wait for the result before proceeding.\n\n")
+
+ // Tool definitions
+ sb.WriteString(FormatToolsForPrompt(AvailableTools()))
+ sb.WriteString("\n")
+
+ // Context instructions
+ sb.WriteString("## Conversation Context\n\n")
+ sb.WriteString("You have access to a conversation history. If a summary of earlier messages\n")
+ sb.WriteString("is provided, use it to maintain continuity. Reference previous topics naturally.\n\n")
+
+ // Formatting instructions
+ sb.WriteString("## Response Format\n\n")
+ sb.WriteString("- Use markdown formatting for readability\n")
+ sb.WriteString("- Present email lists as numbered items with sender, subject, and date\n")
+ sb.WriteString("- Present calendar events with time, title, and attendees\n")
+ sb.WriteString("- Keep responses concise but informative\n")
+ sb.WriteString("- If an error occurs, explain it clearly and suggest alternatives\n")
+
+ return sb.String()
+}
diff --git a/internal/chat/server.go b/internal/chat/server.go
new file mode 100644
index 0000000..6f022a8
--- /dev/null
+++ b/internal/chat/server.go
@@ -0,0 +1,127 @@
+package chat
+
+import (
+ "embed"
+ "html/template"
+ "io/fs"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/nylas/cli/internal/ports"
+)
+
+//go:embed static/css/*.css static/js/*.js
+var staticFiles embed.FS
+
+//go:embed templates/*.gohtml
+var templateFiles embed.FS
+
+// Server is the chat web UI HTTP server.
+type Server struct {
+ addr string
+ agent *Agent
+ agents []Agent
+ agentMu sync.RWMutex // protects agent switching
+ nylas ports.NylasClient
+ grantID string
+ memory *MemoryStore
+ executor *ToolExecutor
+ context *ContextBuilder
+ session *ActiveSession
+ tmpl *template.Template
+}
+
+// ActiveAgent returns the current agent (thread-safe).
+func (s *Server) ActiveAgent() *Agent {
+ s.agentMu.RLock()
+ defer s.agentMu.RUnlock()
+ return s.agent
+}
+
+// SetAgent switches the active agent by type. Returns false if not found.
+func (s *Server) SetAgent(agentType AgentType) bool {
+ agent := FindAgent(s.agents, agentType)
+ if agent == nil {
+ return false
+ }
+ s.agentMu.Lock()
+ s.agent = agent
+ s.context = NewContextBuilder(agent, s.memory, s.grantID)
+ s.agentMu.Unlock()
+ return true
+}
+
+// NewServer creates a new chat Server.
+func NewServer(addr string, agent *Agent, agents []Agent, nylas ports.NylasClient, grantID string, memory *MemoryStore) *Server {
+ executor := NewToolExecutor(nylas, grantID)
+ ctx := NewContextBuilder(agent, memory, grantID)
+
+ tmpl, _ := template.New("").ParseFS(templateFiles, "templates/*.gohtml")
+
+ return &Server{
+ addr: addr,
+ agent: agent,
+ agents: agents,
+ nylas: nylas,
+ grantID: grantID,
+ memory: memory,
+ executor: executor,
+ context: ctx,
+ session: NewActiveSession(),
+ tmpl: tmpl,
+ }
+}
+
+// Start starts the HTTP server and blocks until interrupted.
+func (s *Server) Start() error {
+ mux := http.NewServeMux()
+
+ // API routes
+ mux.HandleFunc("/api/chat", s.handleChat)
+ mux.HandleFunc("/api/conversations", s.handleConversations)
+ mux.HandleFunc("/api/conversations/", s.handleConversationByID)
+ mux.HandleFunc("/api/config", s.handleConfig)
+ mux.HandleFunc("/api/health", s.handleHealth)
+
+ // Static files
+ staticFS, _ := fs.Sub(staticFiles, "static")
+ fileServer := http.FileServer(http.FS(staticFS))
+
+ noCacheHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
+ fileServer.ServeHTTP(w, r)
+ })
+
+ mux.Handle("/css/", noCacheHandler)
+ mux.Handle("/js/", noCacheHandler)
+
+ // Index page
+ mux.HandleFunc("/", s.handleIndex)
+
+ server := &http.Server{
+ Addr: s.addr,
+ Handler: mux,
+ ReadHeaderTimeout: 10 * time.Second,
+ WriteTimeout: 120 * time.Second, // long for SSE streaming
+ IdleTimeout: 120 * time.Second,
+ MaxHeaderBytes: 1 << 20,
+ }
+
+ return server.ListenAndServe()
+}
+
+func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" {
+ http.NotFound(w, r)
+ return
+ }
+
+ if s.tmpl == nil {
+ http.Error(w, "templates not loaded", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _ = s.tmpl.ExecuteTemplate(w, "index.gohtml", nil)
+}
diff --git a/internal/chat/session.go b/internal/chat/session.go
new file mode 100644
index 0000000..a2f8de3
--- /dev/null
+++ b/internal/chat/session.go
@@ -0,0 +1,28 @@
+package chat
+
+import "sync"
+
+// ActiveSession tracks the current conversation for a browser session.
+type ActiveSession struct {
+ conversationID string
+ mu sync.RWMutex
+}
+
+// NewActiveSession creates a new ActiveSession.
+func NewActiveSession() *ActiveSession {
+ return &ActiveSession{}
+}
+
+// Get returns the current conversation ID.
+func (s *ActiveSession) Get() string {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.conversationID
+}
+
+// Set updates the current conversation ID.
+func (s *ActiveSession) Set(id string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.conversationID = id
+}
diff --git a/internal/chat/static/css/chat.css b/internal/chat/static/css/chat.css
new file mode 100644
index 0000000..2a2ae69
--- /dev/null
+++ b/internal/chat/static/css/chat.css
@@ -0,0 +1,519 @@
+/* Nylas Chat — Modern UI */
+:root {
+ --bg-primary: #212121;
+ --bg-secondary: #171717;
+ --bg-sidebar: #171717;
+ --bg-input: #2f2f2f;
+ --bg-hover: #2f2f2f;
+ --bg-message-user: #303030;
+ --bg-message-assistant: transparent;
+ --bg-tool: rgba(255,255,255,0.04);
+ --text-primary: #ececec;
+ --text-secondary: #a0a0a0;
+ --text-muted: #666;
+ --border-color: rgba(255,255,255,0.08);
+ --accent: #8b5cf6;
+ --accent-hover: #a78bfa;
+ --accent-subtle: rgba(139,92,246,0.12);
+ --danger: #f87171;
+ --success: #34d399;
+ --radius: 12px;
+ --radius-sm: 8px;
+ --sidebar-width: 260px;
+ --shadow-sm: 0 1px 2px rgba(0,0,0,0.3);
+ --transition: 150ms ease;
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ --bg-primary: #ffffff;
+ --bg-secondary: #f9fafb;
+ --bg-sidebar: #f9fafb;
+ --bg-input: #ffffff;
+ --bg-hover: #f3f4f6;
+ --bg-message-user: #f3f4f6;
+ --bg-message-assistant: transparent;
+ --bg-tool: rgba(0,0,0,0.03);
+ --text-primary: #111827;
+ --text-secondary: #6b7280;
+ --text-muted: #9ca3af;
+ --border-color: rgba(0,0,0,0.08);
+ --accent: #7c3aed;
+ --accent-hover: #6d28d9;
+ --accent-subtle: rgba(124,58,237,0.08);
+ --shadow-sm: 0 1px 2px rgba(0,0,0,0.06);
+ }
+}
+
+* { box-sizing: border-box; margin: 0; padding: 0; }
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui, sans-serif;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ height: 100vh;
+ overflow: hidden;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.app {
+ display: flex;
+ height: 100vh;
+}
+
+/* Sidebar */
+.sidebar {
+ width: var(--sidebar-width);
+ background: var(--bg-sidebar);
+ border-right: 1px solid var(--border-color);
+ display: flex;
+ flex-direction: column;
+ flex-shrink: 0;
+}
+
+.sidebar-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.sidebar-header h2 {
+ font-size: 14px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+}
+
+.btn-new {
+ width: 30px;
+ height: 30px;
+ border: 1px solid var(--border-color);
+ background: transparent;
+ color: var(--text-secondary);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ font-size: 16px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all var(--transition);
+}
+
+.btn-new:hover {
+ background: var(--accent);
+ color: #fff;
+ border-color: var(--accent);
+}
+
+.conversation-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 6px 8px;
+}
+
+.conv-item {
+ padding: 10px 12px;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ margin-bottom: 1px;
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ transition: background var(--transition);
+}
+
+.conv-item:hover { background: var(--bg-hover); }
+.conv-item.active { background: var(--accent-subtle); }
+
+.conv-item-content { flex: 1; min-width: 0; }
+
+.conv-title {
+ font-size: 13px;
+ font-weight: 500;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.4;
+}
+
+.conv-preview {
+ font-size: 12px;
+ color: var(--text-muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin-top: 2px;
+ line-height: 1.3;
+}
+
+.conv-delete {
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ font-size: 13px;
+ padding: 2px 4px;
+ opacity: 0;
+ flex-shrink: 0;
+ transition: opacity var(--transition);
+ border-radius: 4px;
+}
+
+.conv-item:hover .conv-delete { opacity: 0.6; }
+.conv-delete:hover { opacity: 1 !important; color: var(--danger); }
+
+.sidebar-footer {
+ padding: 12px 14px;
+ border-top: 1px solid var(--border-color);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.sidebar-footer.single-agent .agent-select { pointer-events: none; opacity: 0.5; }
+
+.agent-label {
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ flex-shrink: 0;
+}
+
+.agent-select {
+ flex: 1;
+ min-width: 0;
+ background: var(--bg-input);
+ color: var(--text-primary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-sm);
+ padding: 6px 10px;
+ font-size: 13px;
+ font-family: inherit;
+ cursor: pointer;
+ outline: none;
+ transition: border-color var(--transition);
+ appearance: auto;
+}
+
+.agent-select:focus,
+.agent-select:hover { border-color: var(--accent); }
+
+/* Main Chat Area */
+.chat-main {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ background: var(--bg-primary);
+}
+
+.chat-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 24px;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.chat-header h3 {
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--text-secondary);
+}
+
+.btn-toggle-sidebar {
+ display: none;
+ background: none;
+ border: none;
+ color: var(--text-secondary);
+ font-size: 18px;
+ cursor: pointer;
+ padding: 4px;
+ border-radius: 6px;
+ transition: background var(--transition);
+}
+
+.btn-toggle-sidebar:hover { background: var(--bg-hover); }
+
+.messages {
+ flex: 1;
+ overflow-y: auto;
+ padding: 24px;
+ scroll-behavior: smooth;
+}
+
+/* Welcome Screen */
+.welcome {
+ text-align: center;
+ padding: 80px 20px 40px;
+}
+
+.welcome h2 {
+ margin-bottom: 8px;
+ font-size: 28px;
+ font-weight: 600;
+ letter-spacing: -0.02em;
+}
+
+.welcome p {
+ color: var(--text-secondary);
+ margin-bottom: 32px;
+ font-size: 15px;
+}
+
+.suggestions {
+ display: flex;
+ gap: 10px;
+ justify-content: center;
+ flex-wrap: wrap;
+}
+
+.suggestion {
+ padding: 10px 18px;
+ background: var(--bg-input);
+ border: 1px solid var(--border-color);
+ color: var(--text-primary);
+ border-radius: 100px;
+ cursor: pointer;
+ font-size: 13px;
+ font-family: inherit;
+ transition: all var(--transition);
+}
+
+.suggestion:hover {
+ border-color: var(--accent);
+ background: var(--accent-subtle);
+}
+
+/* Messages */
+.message {
+ display: flex;
+ margin-bottom: 20px;
+ max-width: 720px;
+ margin-left: auto;
+ margin-right: auto;
+ width: 100%;
+}
+
+.message.user { justify-content: flex-end; }
+
+.message-content {
+ padding: 12px 16px;
+ border-radius: 18px;
+ font-size: 14px;
+ line-height: 1.6;
+ word-break: break-word;
+ max-width: 85%;
+}
+
+.message.user .message-content {
+ background: var(--bg-message-user);
+ border-radius: 18px 18px 4px 18px;
+}
+
+.message.assistant .message-content {
+ background: var(--bg-message-assistant);
+ padding-left: 0;
+ padding-right: 0;
+ max-width: 100%;
+}
+
+.message-content p { margin-bottom: 10px; }
+.message-content p:last-child { margin-bottom: 0; }
+
+.message-content code {
+ background: rgba(139,92,246,0.1);
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 13px;
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
+}
+
+.message-content pre {
+ background: rgba(0,0,0,0.4);
+ padding: 14px 16px;
+ border-radius: var(--radius-sm);
+ overflow-x: auto;
+ margin: 10px 0;
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+@media (prefers-color-scheme: light) {
+ .message-content pre { background: #f3f4f6; }
+}
+
+.message-content pre code {
+ background: none;
+ padding: 0;
+ font-size: inherit;
+}
+
+.message-content ul, .message-content ol {
+ padding-left: 20px;
+ margin: 6px 0;
+}
+
+.message-content li { margin-bottom: 4px; }
+
+.message-content a {
+ color: var(--accent);
+ text-decoration: none;
+}
+
+.message-content a:hover { text-decoration: underline; }
+
+/* Tool Call Indicators */
+.tool-indicator {
+ font-size: 12px;
+ color: var(--text-secondary);
+ padding: 8px 14px;
+ background: var(--bg-tool);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-sm);
+ margin-bottom: 10px;
+ max-width: 720px;
+ margin-left: auto;
+ margin-right: auto;
+ cursor: pointer;
+ transition: background var(--transition);
+}
+
+.tool-indicator:hover { background: var(--bg-hover); }
+.tool-indicator .tool-name { font-weight: 600; color: var(--accent); }
+
+.tool-details {
+ display: none;
+ font-size: 12px;
+ padding: 10px;
+ background: rgba(0,0,0,0.15);
+ border-radius: 6px;
+ margin-top: 6px;
+ white-space: pre-wrap;
+ max-height: 200px;
+ overflow-y: auto;
+ font-family: 'SF Mono', 'Fira Code', monospace;
+ line-height: 1.5;
+}
+
+@media (prefers-color-scheme: light) {
+ .tool-details { background: rgba(0,0,0,0.04); }
+}
+
+.tool-indicator.expanded .tool-details { display: block; }
+
+/* Thinking Indicator */
+.thinking {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ color: var(--text-muted);
+ font-size: 13px;
+ padding: 12px 0;
+ max-width: 720px;
+ margin: 0 auto;
+}
+
+.thinking-dots span {
+ display: inline-block;
+ width: 5px;
+ height: 5px;
+ background: var(--accent);
+ border-radius: 50%;
+ animation: pulse 1.4s infinite ease-in-out;
+}
+
+.thinking-dots span:nth-child(2) { animation-delay: 0.2s; }
+.thinking-dots span:nth-child(3) { animation-delay: 0.4s; }
+
+@keyframes pulse {
+ 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
+ 40% { opacity: 1; transform: scale(1); }
+}
+
+/* Input Area */
+.input-area {
+ padding: 16px 24px 24px;
+ background: var(--bg-primary);
+}
+
+.input-wrapper {
+ display: flex;
+ align-items: flex-end;
+ gap: 8px;
+ background: var(--bg-input);
+ border: 1px solid var(--border-color);
+ border-radius: 16px;
+ padding: 6px 6px 6px 16px;
+ max-width: 720px;
+ margin: 0 auto;
+ transition: border-color var(--transition), box-shadow var(--transition);
+}
+
+.input-wrapper:focus-within {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px var(--accent-subtle);
+}
+
+#chat-input {
+ flex: 1;
+ border: none;
+ background: transparent;
+ color: var(--text-primary);
+ font-size: 14px;
+ resize: none;
+ outline: none;
+ max-height: 120px;
+ padding: 8px 0;
+ font-family: inherit;
+ line-height: 1.5;
+}
+
+#chat-input::placeholder { color: var(--text-muted); }
+
+.btn-send {
+ width: 34px;
+ height: 34px;
+ background: var(--accent);
+ border: none;
+ color: #fff;
+ border-radius: 10px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ transition: background var(--transition), transform var(--transition);
+}
+
+.btn-send:hover {
+ background: var(--accent-hover);
+ transform: scale(1.04);
+}
+
+.btn-send:disabled { opacity: 0.3; cursor: not-allowed; transform: none; }
+
+/* Scrollbar */
+::-webkit-scrollbar { width: 6px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb { background: rgba(128,128,128,0.3); border-radius: 3px; }
+::-webkit-scrollbar-thumb:hover { background: rgba(128,128,128,0.5); }
+
+/* Responsive */
+@media (max-width: 768px) {
+ .sidebar {
+ position: fixed;
+ left: -260px;
+ z-index: 10;
+ height: 100%;
+ transition: left 0.2s ease;
+ }
+ .sidebar.open { left: 0; }
+ .btn-toggle-sidebar { display: block; }
+ .message { max-width: 95%; }
+ .input-wrapper { max-width: 100%; }
+}
diff --git a/internal/chat/static/js/api.js b/internal/chat/static/js/api.js
new file mode 100644
index 0000000..8246383
--- /dev/null
+++ b/internal/chat/static/js/api.js
@@ -0,0 +1,76 @@
+// api.js — Chat API client
+const ChatAPI = {
+ async sendMessage(convId, message, onEvent) {
+ const body = { message };
+ if (convId) body.conversation_id = convId;
+
+ const resp = await fetch('/api/chat', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+
+ if (!resp.ok) throw new Error(await resp.text());
+
+ const reader = resp.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split('\n');
+ buffer = lines.pop() || '';
+
+ let currentEvent = '';
+ for (const line of lines) {
+ if (line.startsWith('event: ')) {
+ currentEvent = line.slice(7);
+ } else if (line.startsWith('data: ') && currentEvent) {
+ try {
+ const data = JSON.parse(line.slice(6));
+ onEvent(currentEvent, data);
+ } catch { /* skip malformed */ }
+ currentEvent = '';
+ }
+ }
+ }
+ },
+
+ async listConversations() {
+ const resp = await fetch('/api/conversations');
+ return resp.json();
+ },
+
+ async createConversation() {
+ const resp = await fetch('/api/conversations', { method: 'POST' });
+ return resp.json();
+ },
+
+ async getConversation(id) {
+ const resp = await fetch('/api/conversations/' + id);
+ return resp.json();
+ },
+
+ async deleteConversation(id) {
+ const resp = await fetch('/api/conversations/' + id, { method: 'DELETE' });
+ return resp.json();
+ },
+
+ async getConfig() {
+ const resp = await fetch('/api/config');
+ return resp.json();
+ },
+
+ async switchAgent(agent) {
+ const resp = await fetch('/api/config', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ agent }),
+ });
+ if (!resp.ok) throw new Error(await resp.text());
+ return resp.json();
+ },
+};
diff --git a/internal/chat/static/js/chat.js b/internal/chat/static/js/chat.js
new file mode 100644
index 0000000..573c31c
--- /dev/null
+++ b/internal/chat/static/js/chat.js
@@ -0,0 +1,210 @@
+// chat.js — Main chat UI
+const Chat = {
+ conversationId: null,
+ sending: false,
+
+ init() {
+ const form = document.getElementById('chat-form');
+ const input = document.getElementById('chat-input');
+
+ form.addEventListener('submit', (e) => {
+ e.preventDefault();
+ this.send();
+ });
+
+ // Auto-resize textarea
+ input.addEventListener('input', () => {
+ input.style.height = 'auto';
+ input.style.height = Math.min(input.scrollHeight, 120) + 'px';
+ });
+
+ // Enter to send, Shift+Enter for newline
+ input.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ this.send();
+ }
+ });
+
+ // Suggestion buttons
+ document.querySelectorAll('.suggestion').forEach(btn => {
+ btn.addEventListener('click', () => {
+ input.value = btn.dataset.msg;
+ this.send();
+ });
+ });
+ },
+
+ async send() {
+ const input = document.getElementById('chat-input');
+ const message = input.value.trim();
+ if (!message || this.sending) return;
+
+ this.sending = true;
+ this.hideWelcome();
+ this.appendMessage('user', message);
+ input.value = '';
+ input.style.height = 'auto';
+ document.getElementById('btn-send').disabled = true;
+
+ const thinkingEl = this.showThinking();
+
+ try {
+ await ChatAPI.sendMessage(this.conversationId, message, (event, data) => {
+ switch (event) {
+ case 'thinking':
+ // Already showing indicator
+ break;
+ case 'tool_call':
+ this.appendToolCall(data);
+ break;
+ case 'tool_result':
+ this.appendToolResult(data);
+ break;
+ case 'message':
+ thinkingEl.remove();
+ this.appendMessage('assistant', data.content);
+ break;
+ case 'error':
+ thinkingEl.remove();
+ this.appendMessage('assistant', 'Error: ' + data.error);
+ break;
+ case 'done':
+ if (data.conversation_id) {
+ this.conversationId = data.conversation_id;
+ }
+ if (data.title && data.title !== 'New conversation') {
+ document.getElementById('chat-title').textContent = data.title;
+ }
+ Sidebar.activeId = this.conversationId;
+ Sidebar.refresh();
+ break;
+ }
+ });
+ } catch (err) {
+ thinkingEl.remove();
+ this.appendMessage('assistant', 'Connection error: ' + err.message);
+ }
+
+ this.sending = false;
+ document.getElementById('btn-send').disabled = false;
+ input.focus();
+ },
+
+ loadConversation(conv) {
+ this.conversationId = conv.id;
+ const messages = document.getElementById('messages');
+ messages.innerHTML = '';
+ this.hideWelcome();
+
+ document.getElementById('chat-title').textContent = conv.title || 'New conversation';
+
+ for (const msg of conv.messages) {
+ if (msg.role === 'user' || msg.role === 'assistant') {
+ this.appendMessage(msg.role, msg.content);
+ } else if (msg.role === 'tool_call') {
+ try {
+ const call = JSON.parse(msg.content);
+ this.appendToolCall(call);
+ } catch { /* skip */ }
+ } else if (msg.role === 'tool_result') {
+ try {
+ const result = JSON.parse(msg.content);
+ this.appendToolResult(result);
+ } catch { /* skip */ }
+ }
+ }
+
+ this.scrollToBottom();
+ },
+
+ startNew(id) {
+ this.conversationId = id;
+ const messages = document.getElementById('messages');
+ messages.innerHTML = '';
+ document.getElementById('chat-title').textContent = 'New conversation';
+ this.showWelcome();
+ },
+
+ appendMessage(role, content) {
+ const messages = document.getElementById('messages');
+ const div = document.createElement('div');
+ div.className = 'message ' + role;
+
+ const bubble = document.createElement('div');
+ bubble.className = 'message-content';
+
+ if (role === 'assistant') {
+ bubble.innerHTML = Markdown.render(content);
+ } else {
+ bubble.textContent = content;
+ }
+
+ div.appendChild(bubble);
+ messages.appendChild(div);
+ this.scrollToBottom();
+ },
+
+ appendToolCall(data) {
+ const messages = document.getElementById('messages');
+ const div = document.createElement('div');
+ div.className = 'tool-indicator';
+ div.innerHTML = '🔧 Calling ' +
+ this.escapeHtml(data.name) + '...' +
+ '' + this.escapeHtml(JSON.stringify(data.args, null, 2)) + '
';
+ div.addEventListener('click', () => div.classList.toggle('expanded'));
+ messages.appendChild(div);
+ this.scrollToBottom();
+ },
+
+ appendToolResult(data) {
+ const messages = document.getElementById('messages');
+ const div = document.createElement('div');
+ div.className = 'tool-indicator';
+ const label = data.error ? '❌ Error' : '✅ Result';
+ const detail = data.error || JSON.stringify(data.data, null, 2);
+ div.innerHTML = label + ': ' +
+ this.escapeHtml(data.name) + '' +
+ '' + this.escapeHtml(detail) + '
';
+ div.addEventListener('click', () => div.classList.toggle('expanded'));
+ messages.appendChild(div);
+ this.scrollToBottom();
+ },
+
+ showThinking() {
+ const messages = document.getElementById('messages');
+ const div = document.createElement('div');
+ div.className = 'thinking';
+ div.innerHTML = '
Thinking...';
+ messages.appendChild(div);
+ this.scrollToBottom();
+ return div;
+ },
+
+ hideWelcome() {
+ const welcome = document.getElementById('welcome');
+ if (welcome) welcome.style.display = 'none';
+ },
+
+ showWelcome() {
+ const welcome = document.getElementById('welcome');
+ if (welcome) welcome.style.display = '';
+ },
+
+ scrollToBottom() {
+ const messages = document.getElementById('messages');
+ messages.scrollTop = messages.scrollHeight;
+ },
+
+ escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
+};
+
+// Initialize on DOM load
+document.addEventListener('DOMContentLoaded', () => {
+ Chat.init();
+ Sidebar.init();
+});
diff --git a/internal/chat/static/js/markdown.js b/internal/chat/static/js/markdown.js
new file mode 100644
index 0000000..475720b
--- /dev/null
+++ b/internal/chat/static/js/markdown.js
@@ -0,0 +1,51 @@
+// markdown.js — Lightweight markdown renderer
+const Markdown = {
+ render(text) {
+ if (!text) return '';
+ let html = this.escape(text);
+
+ // Code blocks
+ html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '$2
');
+
+ // Inline code
+ html = html.replace(/`([^`]+)`/g, '$1');
+
+ // Bold
+ html = html.replace(/\*\*(.+?)\*\*/g, '$1');
+
+ // Italic
+ html = html.replace(/\*(.+?)\*/g, '$1');
+
+ // Headers
+ html = html.replace(/^### (.+)$/gm, '$1
');
+ html = html.replace(/^## (.+)$/gm, '$1
');
+ html = html.replace(/^# (.+)$/gm, '$1
');
+
+ // Unordered lists
+ html = html.replace(/^[\s]*[-*] (.+)$/gm, '$1');
+ html = html.replace(/(.*<\/li>\n?)+/g, '');
+
+ // Ordered lists
+ html = html.replace(/^\d+\. (.+)$/gm, '$1');
+
+ // Links
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1');
+
+ // Paragraphs
+ html = html.replace(/\n\n/g, '
');
+ html = '
' + html + '
';
+ html = html.replace(/\s*<(h[234]|ul|ol|pre|li)/g, '<$1');
+ html = html.replace(/<\/(h[234]|ul|ol|pre|li)>\s*<\/p>/g, '$1>');
+
+ // Line breaks
+ html = html.replace(/\n/g, '
');
+
+ return html;
+ },
+
+ escape(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
+};
diff --git a/internal/chat/static/js/sidebar.js b/internal/chat/static/js/sidebar.js
new file mode 100644
index 0000000..7079e07
--- /dev/null
+++ b/internal/chat/static/js/sidebar.js
@@ -0,0 +1,133 @@
+// sidebar.js — Conversation list management
+const Sidebar = {
+ activeId: null,
+ currentAgent: null,
+
+ async init() {
+ await this.refresh();
+ document.getElementById('btn-new-chat').addEventListener('click', () => this.newChat());
+ document.getElementById('btn-toggle-sidebar').addEventListener('click', () => this.toggle());
+
+ // Load agent info and populate dropdown
+ try {
+ const config = await ChatAPI.getConfig();
+ this.currentAgent = config.agent;
+ this.populateAgentDropdown(config.agent, config.available);
+ } catch { /* ignore */ }
+
+ // Agent switch handler
+ document.getElementById('agent-select').addEventListener('change', (e) => {
+ this.switchAgent(e.target.value);
+ });
+ },
+
+ populateAgentDropdown(active, available) {
+ const select = document.getElementById('agent-select');
+ select.innerHTML = '';
+
+ for (const agent of available) {
+ const option = document.createElement('option');
+ option.value = agent;
+ option.textContent = agent.charAt(0).toUpperCase() + agent.slice(1);
+ if (agent === active) option.selected = true;
+ select.appendChild(option);
+ }
+
+ // Hide dropdown if only one agent
+ select.parentElement.classList.toggle('single-agent', available.length <= 1);
+ },
+
+ async switchAgent(agent) {
+ if (agent === this.currentAgent) return;
+ try {
+ const config = await ChatAPI.switchAgent(agent);
+ this.currentAgent = config.agent;
+ this.populateAgentDropdown(config.agent, config.available);
+ } catch (err) {
+ // Revert dropdown on failure
+ const select = document.getElementById('agent-select');
+ select.value = this.currentAgent;
+ }
+ },
+
+ async refresh() {
+ const conversations = await ChatAPI.listConversations();
+ const list = document.getElementById('conversation-list');
+ list.innerHTML = '';
+
+ for (const conv of conversations) {
+ const el = document.createElement('div');
+ el.className = 'conv-item' + (conv.id === this.activeId ? ' active' : '');
+ el.dataset.id = conv.id;
+
+ const content = document.createElement('div');
+ content.className = 'conv-item-content';
+
+ const title = document.createElement('div');
+ title.className = 'conv-title';
+ title.textContent = conv.title || 'New conversation';
+ content.appendChild(title);
+
+ if (conv.preview) {
+ const preview = document.createElement('div');
+ preview.className = 'conv-preview';
+ preview.textContent = conv.preview;
+ content.appendChild(preview);
+ }
+
+ el.appendChild(content);
+
+ const delBtn = document.createElement('button');
+ delBtn.className = 'conv-delete';
+ delBtn.textContent = '✕';
+ delBtn.title = 'Delete conversation';
+ delBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ this.deleteConversation(conv.id);
+ });
+ el.appendChild(delBtn);
+
+ el.addEventListener('click', () => this.loadConversation(conv.id));
+ list.appendChild(el);
+ }
+ },
+
+ async loadConversation(id) {
+ this.activeId = id;
+ const conv = await ChatAPI.getConversation(id);
+ Chat.loadConversation(conv);
+ this.highlightActive();
+ this.closeMobile();
+ },
+
+ async newChat() {
+ const conv = await ChatAPI.createConversation();
+ this.activeId = conv.id;
+ Chat.startNew(conv.id);
+ await this.refresh();
+ this.closeMobile();
+ },
+
+ async deleteConversation(id) {
+ await ChatAPI.deleteConversation(id);
+ if (this.activeId === id) {
+ this.activeId = null;
+ Chat.startNew(null);
+ }
+ await this.refresh();
+ },
+
+ highlightActive() {
+ document.querySelectorAll('.conv-item').forEach(el => {
+ el.classList.toggle('active', el.dataset.id === this.activeId);
+ });
+ },
+
+ toggle() {
+ document.getElementById('sidebar').classList.toggle('open');
+ },
+
+ closeMobile() {
+ document.getElementById('sidebar').classList.remove('open');
+ }
+};
diff --git a/internal/chat/templates/index.gohtml b/internal/chat/templates/index.gohtml
new file mode 100644
index 0000000..5caafb2
--- /dev/null
+++ b/internal/chat/templates/index.gohtml
@@ -0,0 +1,65 @@
+
+
+
+
+
+ Nylas Chat
+
+
+
+
+
+
+
+
+
+
+
+
Welcome to Nylas Chat
+
Ask me about your emails, calendar events, or contacts.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/internal/chat/tools.go b/internal/chat/tools.go
new file mode 100644
index 0000000..b2cc186
--- /dev/null
+++ b/internal/chat/tools.go
@@ -0,0 +1,166 @@
+package chat
+
+import (
+ "encoding/json"
+ "strings"
+)
+
+// Tool represents a tool available to the AI agent.
+type Tool struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Parameters []ToolParameter `json:"parameters"`
+}
+
+// ToolParameter describes a parameter for a tool.
+type ToolParameter struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Description string `json:"description"`
+ Required bool `json:"required"`
+}
+
+// ToolCall represents a parsed tool call from agent output.
+type ToolCall struct {
+ Name string `json:"name"`
+ Args map[string]any `json:"args"`
+}
+
+// ToolResult represents the result of executing a tool call.
+type ToolResult struct {
+ Name string `json:"name"`
+ Data any `json:"data,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// toolCallPrefix is the marker agents use to invoke tools.
+const toolCallPrefix = "TOOL_CALL:"
+
+// toolResultPrefix is the marker for returning results.
+const toolResultPrefix = "TOOL_RESULT:"
+
+// AvailableTools returns the tools exposed to AI agents.
+func AvailableTools() []Tool {
+ return []Tool{
+ {
+ Name: "list_emails",
+ Description: "List recent emails from the user's inbox",
+ Parameters: []ToolParameter{
+ {Name: "limit", Type: "number", Description: "Max emails to return (default 10)", Required: false},
+ {Name: "subject", Type: "string", Description: "Filter by subject keyword", Required: false},
+ {Name: "from", Type: "string", Description: "Filter by sender email", Required: false},
+ {Name: "unread", Type: "boolean", Description: "Only show unread emails", Required: false},
+ },
+ },
+ {
+ Name: "read_email",
+ Description: "Read a specific email by ID to see full body content",
+ Parameters: []ToolParameter{
+ {Name: "id", Type: "string", Description: "The email/message ID", Required: true},
+ },
+ },
+ {
+ Name: "search_emails",
+ Description: "Search emails by query string",
+ Parameters: []ToolParameter{
+ {Name: "query", Type: "string", Description: "Search query (e.g. 'from:sarah budget')", Required: true},
+ {Name: "limit", Type: "number", Description: "Max results (default 10)", Required: false},
+ },
+ },
+ {
+ Name: "send_email",
+ Description: "Send a new email",
+ Parameters: []ToolParameter{
+ {Name: "to", Type: "string", Description: "Recipient email address", Required: true},
+ {Name: "subject", Type: "string", Description: "Email subject line", Required: true},
+ {Name: "body", Type: "string", Description: "Email body (plain text)", Required: true},
+ },
+ },
+ {
+ Name: "list_events",
+ Description: "List upcoming calendar events",
+ Parameters: []ToolParameter{
+ {Name: "limit", Type: "number", Description: "Max events to return (default 10)", Required: false},
+ {Name: "calendar_id", Type: "string", Description: "Calendar ID (default: primary)", Required: false},
+ },
+ },
+ {
+ Name: "create_event",
+ Description: "Create a new calendar event",
+ Parameters: []ToolParameter{
+ {Name: "title", Type: "string", Description: "Event title", Required: true},
+ {Name: "start_time", Type: "string", Description: "Start time (RFC3339, e.g. 2026-02-12T14:00:00Z)", Required: true},
+ {Name: "end_time", Type: "string", Description: "End time (RFC3339)", Required: true},
+ {Name: "calendar_id", Type: "string", Description: "Calendar ID (default: primary)", Required: false},
+ {Name: "description", Type: "string", Description: "Event description", Required: false},
+ },
+ },
+ {
+ Name: "list_contacts",
+ Description: "List contacts from the address book",
+ Parameters: []ToolParameter{
+ {Name: "limit", Type: "number", Description: "Max contacts to return (default 10)", Required: false},
+ {Name: "query", Type: "string", Description: "Search by name or email", Required: false},
+ },
+ },
+ {
+ Name: "list_folders",
+ Description: "List email folders/labels",
+ Parameters: []ToolParameter{},
+ },
+ }
+}
+
+// ParseToolCalls extracts TOOL_CALL: lines from agent output.
+// Returns parsed tool calls and the remaining text (non-tool-call content).
+func ParseToolCalls(output string) ([]ToolCall, string) {
+ var calls []ToolCall
+ var textParts []string
+
+ lines := strings.Split(output, "\n")
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if strings.HasPrefix(trimmed, toolCallPrefix) {
+ jsonStr := strings.TrimSpace(strings.TrimPrefix(trimmed, toolCallPrefix))
+ var call ToolCall
+ if err := json.Unmarshal([]byte(jsonStr), &call); err == nil {
+ calls = append(calls, call)
+ continue
+ }
+ }
+ textParts = append(textParts, line)
+ }
+
+ return calls, strings.TrimSpace(strings.Join(textParts, "\n"))
+}
+
+// FormatToolResult formats a tool result for injection back into the prompt.
+func FormatToolResult(result ToolResult) string {
+ data, err := json.Marshal(result)
+ if err != nil {
+ return toolResultPrefix + " " + `{"error":"failed to marshal result"}`
+ }
+ return toolResultPrefix + " " + string(data)
+}
+
+// FormatToolsForPrompt generates the tool description section for the system prompt.
+func FormatToolsForPrompt(tools []Tool) string {
+ var sb strings.Builder
+ sb.WriteString("Available tools:\n\n")
+
+ for _, t := range tools {
+ sb.WriteString("- **" + t.Name + "**: " + t.Description + "\n")
+ if len(t.Parameters) > 0 {
+ sb.WriteString(" Parameters:\n")
+ for _, p := range t.Parameters {
+ req := ""
+ if p.Required {
+ req = " (required)"
+ }
+ sb.WriteString(" - " + p.Name + " (" + p.Type + "): " + p.Description + req + "\n")
+ }
+ }
+ }
+
+ return sb.String()
+}
diff --git a/internal/chat/tools_test.go b/internal/chat/tools_test.go
new file mode 100644
index 0000000..9ec4349
--- /dev/null
+++ b/internal/chat/tools_test.go
@@ -0,0 +1,354 @@
+package chat
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParseToolCalls(t *testing.T) {
+ tests := []struct {
+ name string
+ output string
+ expectedCalls int
+ expectedText string
+ validateResult func(t *testing.T, calls []ToolCall, text string)
+ }{
+ {
+ name: "no tool calls",
+ output: "Just a regular response\nWith multiple lines",
+ expectedCalls: 0,
+ expectedText: "Just a regular response\nWith multiple lines",
+ },
+ {
+ name: "single tool call",
+ output: `Let me search for emails.
+TOOL_CALL: {"name":"search_emails","args":{"query":"budget","limit":5}}
+I'll search for budget-related emails.`,
+ expectedCalls: 1,
+ expectedText: "Let me search for emails.\nI'll search for budget-related emails.",
+ validateResult: func(t *testing.T, calls []ToolCall, text string) {
+ require.Len(t, calls, 1)
+ assert.Equal(t, "search_emails", calls[0].Name)
+ assert.Equal(t, "budget", calls[0].Args["query"])
+ assert.Equal(t, float64(5), calls[0].Args["limit"])
+ },
+ },
+ {
+ name: "multiple tool calls",
+ output: `I'll list your emails first.
+TOOL_CALL: {"name":"list_emails","args":{"limit":10}}
+Then I'll check your calendar.
+TOOL_CALL: {"name":"list_events","args":{"limit":5}}
+All done!`,
+ expectedCalls: 2,
+ expectedText: "I'll list your emails first.\nThen I'll check your calendar.\nAll done!",
+ validateResult: func(t *testing.T, calls []ToolCall, text string) {
+ require.Len(t, calls, 2)
+ assert.Equal(t, "list_emails", calls[0].Name)
+ assert.Equal(t, "list_events", calls[1].Name)
+ },
+ },
+ {
+ name: "malformed JSON - kept as text",
+ output: "TOOL_CALL: {invalid json}\nValid text here",
+ expectedCalls: 0,
+ expectedText: "TOOL_CALL: {invalid json}\nValid text here",
+ },
+ {
+ name: "tool call with leading whitespace",
+ output: " TOOL_CALL: {\"name\":\"list_folders\",\"args\":{}}\nNext line",
+ expectedCalls: 1,
+ expectedText: "Next line",
+ validateResult: func(t *testing.T, calls []ToolCall, text string) {
+ require.Len(t, calls, 1)
+ assert.Equal(t, "list_folders", calls[0].Name)
+ },
+ },
+ {
+ name: "empty output",
+ output: "",
+ expectedCalls: 0,
+ expectedText: "",
+ },
+ {
+ name: "only tool calls, no text",
+ output: "TOOL_CALL: {\"name\":\"list_contacts\",\"args\":{\"limit\":20}}",
+ expectedCalls: 1,
+ expectedText: "",
+ validateResult: func(t *testing.T, calls []ToolCall, text string) {
+ require.Len(t, calls, 1)
+ assert.Equal(t, "list_contacts", calls[0].Name)
+ },
+ },
+ {
+ name: "tool call with complex args",
+ output: `Sending email now.
+TOOL_CALL: {"name":"send_email","args":{"to":"test@example.com","subject":"Test","body":"Hello\nWorld"}}
+Email sent!`,
+ expectedCalls: 1,
+ expectedText: "Sending email now.\nEmail sent!",
+ validateResult: func(t *testing.T, calls []ToolCall, text string) {
+ require.Len(t, calls, 1)
+ assert.Equal(t, "send_email", calls[0].Name)
+ assert.Equal(t, "test@example.com", calls[0].Args["to"])
+ assert.Equal(t, "Test", calls[0].Args["subject"])
+ assert.Equal(t, "Hello\nWorld", calls[0].Args["body"])
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ calls, text := ParseToolCalls(tt.output)
+ assert.Len(t, calls, tt.expectedCalls, "unexpected number of tool calls")
+ assert.Equal(t, tt.expectedText, text, "unexpected remaining text")
+
+ if tt.validateResult != nil {
+ tt.validateResult(t, calls, text)
+ }
+ })
+ }
+}
+
+func TestFormatToolResult(t *testing.T) {
+ tests := []struct {
+ name string
+ result ToolResult
+ expectedPrefix string
+ validateJSON func(t *testing.T, output string)
+ }{
+ {
+ name: "success with data",
+ result: ToolResult{
+ Name: "list_emails",
+ Data: map[string]any{
+ "emails": []string{"email1", "email2"},
+ "count": 2,
+ },
+ },
+ expectedPrefix: "TOOL_RESULT: ",
+ validateJSON: func(t *testing.T, output string) {
+ assert.Contains(t, output, `"name":"list_emails"`)
+ assert.Contains(t, output, `"data"`)
+ assert.NotContains(t, output, `"error"`)
+ },
+ },
+ {
+ name: "error result",
+ result: ToolResult{
+ Name: "send_email",
+ Error: "failed to send: network timeout",
+ },
+ expectedPrefix: "TOOL_RESULT: ",
+ validateJSON: func(t *testing.T, output string) {
+ assert.Contains(t, output, `"name":"send_email"`)
+ assert.Contains(t, output, `"error":"failed to send: network timeout"`)
+ },
+ },
+ {
+ name: "both data and error",
+ result: ToolResult{
+ Name: "read_email",
+ Data: map[string]string{"partial": "data"},
+ Error: "incomplete read",
+ },
+ expectedPrefix: "TOOL_RESULT: ",
+ validateJSON: func(t *testing.T, output string) {
+ assert.Contains(t, output, `"name":"read_email"`)
+ assert.Contains(t, output, `"data"`)
+ assert.Contains(t, output, `"error"`)
+ },
+ },
+ {
+ name: "empty data",
+ result: ToolResult{
+ Name: "list_folders",
+ },
+ expectedPrefix: "TOOL_RESULT: ",
+ validateJSON: func(t *testing.T, output string) {
+ assert.Contains(t, output, `"name":"list_folders"`)
+ },
+ },
+ {
+ name: "data with special characters",
+ result: ToolResult{
+ Name: "search_emails",
+ Data: map[string]string{
+ "subject": "Test \"quoted\" & ",
+ },
+ },
+ expectedPrefix: "TOOL_RESULT: ",
+ validateJSON: func(t *testing.T, output string) {
+ assert.Contains(t, output, `"name":"search_emails"`)
+ assert.Contains(t, output, `\u003cspecial\u003e`)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ output := FormatToolResult(tt.result)
+ assert.True(t, strings.HasPrefix(output, tt.expectedPrefix), "output should start with TOOL_RESULT:")
+
+ if tt.validateJSON != nil {
+ tt.validateJSON(t, output)
+ }
+ })
+ }
+}
+
+func TestAvailableTools(t *testing.T) {
+ tools := AvailableTools()
+
+ t.Run("returns correct tool count", func(t *testing.T) {
+ // Expecting: list_emails, read_email, search_emails, send_email,
+ // list_events, create_event, list_contacts, list_folders
+ assert.Equal(t, 8, len(tools), "expected 8 tools")
+ })
+
+ t.Run("contains expected tool names", func(t *testing.T) {
+ expectedTools := []string{
+ "list_emails", "read_email", "search_emails", "send_email",
+ "list_events", "create_event", "list_contacts", "list_folders",
+ }
+
+ toolNames := make(map[string]bool)
+ for _, tool := range tools {
+ toolNames[tool.Name] = true
+ }
+
+ for _, expected := range expectedTools {
+ assert.True(t, toolNames[expected], "tool %s should be available", expected)
+ }
+ })
+
+ t.Run("all tools have descriptions", func(t *testing.T) {
+ for _, tool := range tools {
+ assert.NotEmpty(t, tool.Description, "tool %s should have a description", tool.Name)
+ }
+ })
+
+ t.Run("required parameters are marked", func(t *testing.T) {
+ // Find send_email tool
+ var sendEmail *Tool
+ for i, tool := range tools {
+ if tool.Name == "send_email" {
+ sendEmail = &tools[i]
+ break
+ }
+ }
+ require.NotNil(t, sendEmail, "send_email tool should exist")
+
+ // Check required parameters
+ requiredParams := []string{"to", "subject", "body"}
+ for _, param := range sendEmail.Parameters {
+ if contains(requiredParams, param.Name) {
+ assert.True(t, param.Required, "parameter %s should be required", param.Name)
+ }
+ }
+ })
+
+ t.Run("all parameters have types", func(t *testing.T) {
+ for _, tool := range tools {
+ for _, param := range tool.Parameters {
+ assert.NotEmpty(t, param.Type, "parameter %s in tool %s should have a type", param.Name, tool.Name)
+ }
+ }
+ })
+}
+
+func TestFormatToolsForPrompt(t *testing.T) {
+ tools := []Tool{
+ {
+ Name: "test_tool",
+ Description: "A test tool for testing",
+ Parameters: []ToolParameter{
+ {Name: "arg1", Type: "string", Description: "First argument", Required: true},
+ {Name: "arg2", Type: "number", Description: "Second argument", Required: false},
+ },
+ },
+ {
+ Name: "simple_tool",
+ Description: "A tool with no parameters",
+ Parameters: []ToolParameter{},
+ },
+ }
+
+ output := FormatToolsForPrompt(tools)
+
+ t.Run("contains header", func(t *testing.T) {
+ assert.Contains(t, output, "Available tools:")
+ })
+
+ t.Run("contains all tool names", func(t *testing.T) {
+ assert.Contains(t, output, "test_tool")
+ assert.Contains(t, output, "simple_tool")
+ })
+
+ t.Run("contains tool descriptions", func(t *testing.T) {
+ assert.Contains(t, output, "A test tool for testing")
+ assert.Contains(t, output, "A tool with no parameters")
+ })
+
+ t.Run("contains parameter details", func(t *testing.T) {
+ assert.Contains(t, output, "arg1")
+ assert.Contains(t, output, "string")
+ assert.Contains(t, output, "First argument")
+ assert.Contains(t, output, "(required)")
+ })
+
+ t.Run("shows optional parameters", func(t *testing.T) {
+ assert.Contains(t, output, "arg2")
+ assert.Contains(t, output, "number")
+ assert.Contains(t, output, "Second argument")
+ // Should NOT contain "(required)" for arg2
+ lines := strings.Split(output, "\n")
+ for _, line := range lines {
+ if strings.Contains(line, "arg2") {
+ assert.NotContains(t, line, "(required)", "arg2 should not be marked as required")
+ }
+ }
+ })
+
+ t.Run("tool with no parameters omits Parameters section", func(t *testing.T) {
+ // Find the simple_tool in the output
+ lines := strings.Split(output, "\n")
+ foundSimpleTool := false
+ hasParameters := false
+
+ for i, line := range lines {
+ if strings.Contains(line, "simple_tool") {
+ foundSimpleTool = true
+ // Check next few lines for "Parameters:"
+ for j := i + 1; j < len(lines) && j < i+5; j++ {
+ if strings.Contains(lines[j], "- **") {
+ // Hit next tool
+ break
+ }
+ if strings.Contains(lines[j], "Parameters:") {
+ hasParameters = true
+ break
+ }
+ }
+ break
+ }
+ }
+
+ assert.True(t, foundSimpleTool, "should find simple_tool in output")
+ assert.False(t, hasParameters, "simple_tool should not have Parameters section")
+ })
+}
+
+// Helper functions
+
+func contains(slice []string, item string) bool {
+ for _, s := range slice {
+ if s == item {
+ return true
+ }
+ }
+ return false
+}
From 661cb7e191b371d915d260f79395c41583ebda9e Mon Sep 17 00:00:00 2001
From: Qasim
Date: Thu, 12 Feb 2026 23:20:37 -0500
Subject: [PATCH 2/9] feat(chat): add slash commands, approval gating,
streaming, and comprehensive tests
- Add slash commands (/help, /new, /clear, /model, /email, /calendar,
/contacts, /status) with tab completion and server-side execution
- Add approval gating for destructive operations (send_email, create_event)
with channel-based synchronization and SSE approval_required/resolved events
- Add streaming responses with token-by-token SSE delivery and
stream-then-parse strategy for tool call detection
- Split chat.css into chat.css + components.css for maintainability
- Add comprehensive Go unit tests for executor (email/calendar/contacts/
folders tool dispatch), conversation handlers, prompt builder, and session
- Add Playwright E2E tests (28 tests) covering smoke, UI interactions,
slash commands, and API health endpoints
- Fix welcome element preservation when clearing/resetting messages
- Fix async command execution to properly await sidebar operations
---
internal/adapters/nylas/mock_client.go | 3 +
internal/adapters/nylas/mock_contacts.go | 3 +
internal/chat/agent_stream.go | 229 +++++++++
internal/chat/agent_stream_test.go | 333 +++++++++++++
internal/chat/approval.go | 139 ++++++
internal/chat/approval_test.go | 294 +++++++++++
internal/chat/executor_operations_test.go | 512 +++++++++++++++++++
internal/chat/executor_test.go | 438 +++++++++++++++++
internal/chat/handlers.go | 71 ++-
internal/chat/handlers_approval.go | 72 +++
internal/chat/handlers_approval_test.go | 232 +++++++++
internal/chat/handlers_cmd.go | 130 +++++
internal/chat/handlers_cmd_test.go | 270 ++++++++++
internal/chat/handlers_conv_test.go | 569 ++++++++++++++++++++++
internal/chat/prompt_test.go | 102 ++++
internal/chat/server.go | 49 +-
internal/chat/session_test.go | 143 ++++++
internal/chat/static/css/chat.css | 146 ------
internal/chat/static/css/components.css | 280 +++++++++++
internal/chat/static/js/api.js | 32 ++
internal/chat/static/js/chat.js | 153 +++++-
internal/chat/static/js/commands.js | 124 +++++
internal/chat/templates/index.gohtml | 2 +
tests/chat/e2e/smoke.spec.js | 116 +++++
tests/chat/e2e/ui-interactions.spec.js | 204 ++++++++
tests/package.json | 1 +
tests/playwright.config.js | 29 +-
tests/shared/helpers/chat-selectors.js | 54 ++
28 files changed, 4553 insertions(+), 177 deletions(-)
create mode 100644 internal/chat/agent_stream.go
create mode 100644 internal/chat/agent_stream_test.go
create mode 100644 internal/chat/approval.go
create mode 100644 internal/chat/approval_test.go
create mode 100644 internal/chat/executor_operations_test.go
create mode 100644 internal/chat/executor_test.go
create mode 100644 internal/chat/handlers_approval.go
create mode 100644 internal/chat/handlers_approval_test.go
create mode 100644 internal/chat/handlers_cmd.go
create mode 100644 internal/chat/handlers_cmd_test.go
create mode 100644 internal/chat/handlers_conv_test.go
create mode 100644 internal/chat/prompt_test.go
create mode 100644 internal/chat/session_test.go
create mode 100644 internal/chat/static/css/components.css
create mode 100644 internal/chat/static/js/commands.js
create mode 100644 tests/chat/e2e/smoke.spec.js
create mode 100644 tests/chat/e2e/ui-interactions.spec.js
create mode 100644 tests/shared/helpers/chat-selectors.js
diff --git a/internal/adapters/nylas/mock_client.go b/internal/adapters/nylas/mock_client.go
index 9803e1a..f87be63 100644
--- a/internal/adapters/nylas/mock_client.go
+++ b/internal/adapters/nylas/mock_client.go
@@ -88,6 +88,9 @@ type MockClient struct {
CreateEventFunc func(ctx context.Context, grantID, calendarID string, req *domain.CreateEventRequest) (*domain.Event, error)
UpdateEventFunc func(ctx context.Context, grantID, calendarID, eventID string, req *domain.UpdateEventRequest) (*domain.Event, error)
DeleteEventFunc func(ctx context.Context, grantID, calendarID, eventID string) error
+
+ // Contact functions
+ GetContactsFunc func(ctx context.Context, grantID string, params *domain.ContactQueryParams) ([]domain.Contact, error)
}
// NewMockClient creates a new MockClient.
diff --git a/internal/adapters/nylas/mock_contacts.go b/internal/adapters/nylas/mock_contacts.go
index ffa44da..2c95aa8 100644
--- a/internal/adapters/nylas/mock_contacts.go
+++ b/internal/adapters/nylas/mock_contacts.go
@@ -7,6 +7,9 @@ import (
)
func (m *MockClient) GetContacts(ctx context.Context, grantID string, params *domain.ContactQueryParams) ([]domain.Contact, error) {
+ if m.GetContactsFunc != nil {
+ return m.GetContactsFunc(ctx, grantID, params)
+ }
return []domain.Contact{
{
ID: "contact-1",
diff --git a/internal/chat/agent_stream.go b/internal/chat/agent_stream.go
new file mode 100644
index 0000000..4e0484f
--- /dev/null
+++ b/internal/chat/agent_stream.go
@@ -0,0 +1,229 @@
+package chat
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os/exec"
+ "strings"
+)
+
+// TokenCallback is called for each token received during streaming.
+type TokenCallback func(token string)
+
+// SupportsStreaming returns true if the agent supports token-by-token streaming.
+func (a *Agent) SupportsStreaming() bool {
+ return a.Type == AgentClaude || a.Type == AgentOllama
+}
+
+// RunStreaming executes the agent with streaming output.
+// The onToken callback is called for each text chunk received.
+// Returns the complete response text and any error.
+func (a *Agent) RunStreaming(ctx context.Context, prompt string, onToken TokenCallback) (string, error) {
+ switch a.Type {
+ case AgentClaude:
+ return a.streamClaude(ctx, prompt, onToken)
+ case AgentOllama:
+ return a.streamOllama(ctx, prompt, onToken)
+ default:
+ // Fallback: run non-streaming, emit full response as single token
+ return a.fallbackStream(ctx, prompt, onToken)
+ }
+}
+
+// streamClaude streams tokens from Claude using stream-json output format.
+// Each line is a JSON object; we extract text from content_block_delta events.
+func (a *Agent) streamClaude(ctx context.Context, prompt string, onToken TokenCallback) (string, error) {
+ cmd := exec.CommandContext(ctx, a.Path, "-p", "--verbose", "--output-format", "stream-json", prompt)
+ cmd.Env = cleanEnv()
+
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return "", fmt.Errorf("claude stream pipe: %w", err)
+ }
+
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+
+ if err := cmd.Start(); err != nil {
+ return "", fmt.Errorf("claude stream start: %w", err)
+ }
+
+ var full strings.Builder
+ var resultText string // fallback from result event
+ scanner := bufio.NewScanner(stdout)
+ scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // allow large lines
+
+ for scanner.Scan() {
+ line := scanner.Bytes()
+
+ // Try streaming token first
+ token := parseClaudeStreamLine(line)
+ if token != "" {
+ full.WriteString(token)
+ if onToken != nil {
+ onToken(token)
+ }
+ continue
+ }
+
+ // Check for result event (contains full response as fallback)
+ if r := parseClaudeResultLine(line); r != "" {
+ resultText = r
+ }
+ }
+
+ if err := cmd.Wait(); err != nil {
+ // If we got output, return it despite the error
+ if full.Len() > 0 {
+ return full.String(), nil
+ }
+ if resultText != "" {
+ return resultText, nil
+ }
+ return "", fmt.Errorf("claude stream error: %w: %s", err, stderr.String())
+ }
+
+ // Prefer streamed tokens; fall back to result event
+ if full.Len() > 0 {
+ return full.String(), nil
+ }
+ if resultText != "" {
+ return resultText, nil
+ }
+
+ return "", nil
+}
+
+// claudeStreamEvent represents a Claude Code CLI stream-json line.
+// The CLI wraps Anthropic API events in a stream_event envelope:
+//
+// {"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"..."}}}
+//
+// Result events contain the full response:
+//
+// {"type":"result","result":"full text","subtype":"success"}
+type claudeStreamEvent struct {
+ Type string `json:"type"`
+ // stream_event wraps the inner API event
+ Event struct {
+ Type string `json:"type"`
+ Delta struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ } `json:"delta"`
+ } `json:"event"`
+ // result event contains the full response text
+ Result string `json:"result"`
+}
+
+// parseClaudeStreamLine extracts text tokens from a Claude stream-json line.
+func parseClaudeStreamLine(line []byte) string {
+ if len(line) == 0 {
+ return ""
+ }
+
+ var event claudeStreamEvent
+ if err := json.Unmarshal(line, &event); err != nil {
+ return ""
+ }
+
+ // stream_event envelope with nested content_block_delta
+ if event.Type == "stream_event" && event.Event.Type == "content_block_delta" {
+ return event.Event.Delta.Text
+ }
+
+ return ""
+}
+
+// parseClaudeResultLine extracts the full response from a result event.
+func parseClaudeResultLine(line []byte) string {
+ if len(line) == 0 {
+ return ""
+ }
+
+ var event claudeStreamEvent
+ if err := json.Unmarshal(line, &event); err != nil {
+ return ""
+ }
+
+ if event.Type == "result" && event.Result != "" {
+ return event.Result
+ }
+
+ return ""
+}
+
+// streamOllama streams tokens from Ollama by reading stdout chunks.
+func (a *Agent) streamOllama(ctx context.Context, prompt string, onToken TokenCallback) (string, error) {
+ model := a.Model
+ if model == "" {
+ model = "mistral"
+ }
+
+ cmd := exec.CommandContext(ctx, a.Path, "run", model)
+ cmd.Env = cleanEnv()
+ cmd.Stdin = strings.NewReader(prompt)
+
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return "", fmt.Errorf("ollama stream pipe: %w", err)
+ }
+
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+
+ if err := cmd.Start(); err != nil {
+ return "", fmt.Errorf("ollama stream start: %w", err)
+ }
+
+ var full strings.Builder
+ buf := make([]byte, 256)
+
+ for {
+ n, readErr := stdout.Read(buf)
+ if n > 0 {
+ token := string(buf[:n])
+ full.WriteString(token)
+ if onToken != nil {
+ onToken(token)
+ }
+ }
+ if readErr != nil {
+ if readErr != io.EOF {
+ // Non-EOF error, but if we have content, return it
+ if full.Len() > 0 {
+ break
+ }
+ return "", fmt.Errorf("ollama read: %w", readErr)
+ }
+ break
+ }
+ }
+
+ if err := cmd.Wait(); err != nil {
+ if full.Len() > 0 {
+ return strings.TrimSpace(full.String()), nil
+ }
+ return "", fmt.Errorf("ollama stream error: %w: %s", err, stderr.String())
+ }
+
+ return strings.TrimSpace(full.String()), nil
+}
+
+// fallbackStream runs the agent non-streaming and emits the full response as one token.
+func (a *Agent) fallbackStream(ctx context.Context, prompt string, onToken TokenCallback) (string, error) {
+ response, err := a.Run(ctx, prompt)
+ if err != nil {
+ return "", err
+ }
+
+ if onToken != nil && response != "" {
+ onToken(response)
+ }
+
+ return response, nil
+}
diff --git a/internal/chat/agent_stream_test.go b/internal/chat/agent_stream_test.go
new file mode 100644
index 0000000..847fc82
--- /dev/null
+++ b/internal/chat/agent_stream_test.go
@@ -0,0 +1,333 @@
+package chat
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSupportsStreaming(t *testing.T) {
+ tests := []struct {
+ name string
+ agent Agent
+ expected bool
+ }{
+ {
+ name: "claude supports streaming",
+ agent: Agent{
+ Type: AgentClaude,
+ Path: "/usr/bin/claude",
+ },
+ expected: true,
+ },
+ {
+ name: "ollama supports streaming",
+ agent: Agent{
+ Type: AgentOllama,
+ Path: "/usr/bin/ollama",
+ Model: "mistral",
+ },
+ expected: true,
+ },
+ {
+ name: "codex does not support streaming",
+ agent: Agent{
+ Type: AgentCodex,
+ Path: "/usr/bin/codex",
+ },
+ expected: false,
+ },
+ {
+ name: "unknown type does not support streaming",
+ agent: Agent{
+ Type: AgentType("unknown"),
+ Path: "/usr/bin/unknown",
+ },
+ expected: false,
+ },
+ {
+ name: "empty type does not support streaming",
+ agent: Agent{
+ Type: AgentType(""),
+ Path: "/usr/bin/empty",
+ },
+ expected: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := tt.agent.SupportsStreaming()
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestParseClaudeStreamLine(t *testing.T) {
+ tests := []struct {
+ name string
+ line string
+ expected string
+ }{
+ {
+ name: "stream_event with content_block_delta",
+ line: `{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}}`,
+ expected: "Hello",
+ },
+ {
+ name: "stream_event with multiword text",
+ line: `{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello, world!"}}}`,
+ expected: "Hello, world!",
+ },
+ {
+ name: "stream_event with newline in text",
+ line: `{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Line 1\nLine 2"}}}`,
+ expected: "Line 1\nLine 2",
+ },
+ {
+ name: "stream_event with empty text",
+ line: `{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}}`,
+ expected: "",
+ },
+ {
+ name: "stream_event with message_start (not delta)",
+ line: `{"type":"stream_event","event":{"type":"message_start","message":{"id":"msg_123"}}}`,
+ expected: "",
+ },
+ {
+ name: "stream_event with content_block_stop",
+ line: `{"type":"stream_event","event":{"type":"content_block_stop","index":0}}`,
+ expected: "",
+ },
+ {
+ name: "stream_event with message_delta",
+ line: `{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn"}}}`,
+ expected: "",
+ },
+ {
+ name: "non-stream_event type",
+ line: `{"type":"result","result":"Full response","subtype":"success"}`,
+ expected: "",
+ },
+ {
+ name: "empty line",
+ line: "",
+ expected: "",
+ },
+ {
+ name: "whitespace only",
+ line: " ",
+ expected: "",
+ },
+ {
+ name: "invalid JSON",
+ line: `{"type":"stream_event","event":{"type":"content_block_delta","delta":{"text":"Hello}`,
+ expected: "",
+ },
+ {
+ name: "malformed JSON",
+ line: `not json at all`,
+ expected: "",
+ },
+ {
+ name: "valid JSON but unknown type",
+ line: `{"type":"ping","timestamp":1234567890}`,
+ expected: "",
+ },
+ {
+ name: "stream_event with unicode",
+ line: `{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello 👋 世界"}}}`,
+ expected: "Hello 👋 世界",
+ },
+ {
+ name: "stream_event with escaped quotes",
+ line: `{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"He said \"Hi\""}}}`,
+ expected: `He said "Hi"`,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := parseClaudeStreamLine([]byte(tt.line))
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestParseClaudeResultLine(t *testing.T) {
+ tests := []struct {
+ name string
+ line string
+ expected string
+ }{
+ {
+ name: "result event with text",
+ line: `{"type":"result","result":"Hello, world!","subtype":"success"}`,
+ expected: "Hello, world!",
+ },
+ {
+ name: "result event with empty result",
+ line: `{"type":"result","result":"","subtype":"success"}`,
+ expected: "",
+ },
+ {
+ name: "non-result event",
+ line: `{"type":"stream_event","event":{"type":"content_block_delta","delta":{"text":"Hi"}}}`,
+ expected: "",
+ },
+ {
+ name: "empty line",
+ line: "",
+ expected: "",
+ },
+ {
+ name: "invalid JSON",
+ line: `not json`,
+ expected: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := parseClaudeResultLine([]byte(tt.line))
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestFallbackStream(t *testing.T) {
+ tests := []struct {
+ name string
+ agent Agent
+ prompt string
+ expectError bool
+ expectToken bool
+ errorContains string
+ }{
+ {
+ name: "codex agent with non-existent binary",
+ agent: Agent{
+ Type: AgentCodex,
+ Path: "/nonexistent/codex",
+ },
+ prompt: "test prompt",
+ expectError: true,
+ expectToken: false,
+ errorContains: "codex error",
+ },
+ {
+ name: "unknown agent type",
+ agent: Agent{
+ Type: AgentType("unknown"),
+ Path: "/nonexistent/unknown",
+ },
+ prompt: "test prompt",
+ expectError: true,
+ expectToken: false,
+ errorContains: "unsupported agent type",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := context.Background()
+ var receivedToken string
+ var tokenReceived bool
+
+ callback := func(token string) {
+ receivedToken = token
+ tokenReceived = true
+ }
+
+ result, err := tt.agent.fallbackStream(ctx, tt.prompt, callback)
+
+ if tt.expectError {
+ require.Error(t, err)
+ if tt.errorContains != "" {
+ assert.Contains(t, err.Error(), tt.errorContains)
+ }
+ assert.Empty(t, result)
+ assert.False(t, tokenReceived, "should not receive token on error")
+ } else {
+ require.NoError(t, err)
+ if tt.expectToken {
+ assert.True(t, tokenReceived, "should receive token callback")
+ assert.NotEmpty(t, receivedToken)
+ assert.Equal(t, result, receivedToken)
+ }
+ }
+ })
+ }
+}
+
+func TestFallbackStream_CallbackBehavior(t *testing.T) {
+ t.Run("nil callback does not panic", func(t *testing.T) {
+ agent := Agent{
+ Type: AgentCodex,
+ Path: "/nonexistent/codex",
+ }
+
+ ctx := context.Background()
+ _, err := agent.fallbackStream(ctx, "test", nil)
+
+ // Should error because binary doesn't exist, but shouldn't panic
+ assert.Error(t, err)
+ })
+
+ t.Run("callback receives full response", func(t *testing.T) {
+ // This test would require mocking exec.Command or using a real binary
+ // For now, we verify the logic path with a non-existent binary
+ agent := Agent{
+ Type: AgentType("unknown"),
+ Path: "/path/to/agent",
+ }
+
+ ctx := context.Background()
+ callbackCount := 0
+
+ callback := func(token string) {
+ callbackCount++
+ }
+
+ _, err := agent.fallbackStream(ctx, "prompt", callback)
+
+ // Should error with unsupported agent type
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "unsupported agent type")
+ assert.Equal(t, 0, callbackCount, "callback should not be called on error")
+ })
+}
+
+func TestTokenCallback(t *testing.T) {
+ t.Run("multiple tokens collected", func(t *testing.T) {
+ var tokens []string
+ callback := TokenCallback(func(token string) {
+ tokens = append(tokens, token)
+ })
+
+ // Simulate streaming multiple tokens
+ callback("Hello")
+ callback(" ")
+ callback("world")
+ callback("!")
+
+ assert.Equal(t, []string{"Hello", " ", "world", "!"}, tokens)
+ assert.Equal(t, "Hello world!", strings.Join(tokens, ""))
+ })
+
+ t.Run("empty token handling", func(t *testing.T) {
+ var tokens []string
+ callback := TokenCallback(func(token string) {
+ tokens = append(tokens, token)
+ })
+
+ callback("")
+ callback("text")
+ callback("")
+
+ assert.Equal(t, []string{"", "text", ""}, tokens)
+ })
+}
diff --git a/internal/chat/approval.go b/internal/chat/approval.go
new file mode 100644
index 0000000..d9bbe49
--- /dev/null
+++ b/internal/chat/approval.go
@@ -0,0 +1,139 @@
+package chat
+
+import (
+ "sync"
+ "sync/atomic"
+ "time"
+)
+
+// approvalTimeout is how long to wait for user approval before auto-rejecting.
+const approvalTimeout = 5 * time.Minute
+
+// gatedTools lists tools that require user approval before execution.
+var gatedTools = map[string]bool{
+ "send_email": true,
+ "create_event": true,
+}
+
+// IsGated returns true if the tool requires user approval.
+func IsGated(toolName string) bool {
+ return gatedTools[toolName]
+}
+
+// ApprovalDecision is the user's response to an approval request.
+type ApprovalDecision struct {
+ Approved bool `json:"approved"`
+ Reason string `json:"reason,omitempty"`
+}
+
+// PendingApproval represents a tool call waiting for user approval.
+type PendingApproval struct {
+ ID string `json:"id"`
+ Tool string `json:"tool"`
+ Args map[string]any `json:"args"`
+ Preview map[string]any `json:"preview"`
+ ch chan ApprovalDecision
+}
+
+// Wait blocks until the user approves/rejects or the timeout expires.
+func (pa *PendingApproval) Wait() (ApprovalDecision, bool) {
+ select {
+ case decision := <-pa.ch:
+ return decision, true
+ case <-time.After(approvalTimeout):
+ return ApprovalDecision{Approved: false, Reason: "timed out"}, false
+ }
+}
+
+// ApprovalStore manages pending approval requests.
+type ApprovalStore struct {
+ pending sync.Map
+ counter atomic.Int64
+}
+
+// NewApprovalStore creates a new ApprovalStore.
+func NewApprovalStore() *ApprovalStore {
+ return &ApprovalStore{}
+}
+
+// Create registers a new pending approval and returns it.
+func (s *ApprovalStore) Create(call ToolCall, preview map[string]any) *PendingApproval {
+ id := s.nextID()
+ pa := &PendingApproval{
+ ID: id,
+ Tool: call.Name,
+ Args: call.Args,
+ Preview: preview,
+ ch: make(chan ApprovalDecision, 1), // buffered so sender never blocks
+ }
+ s.pending.Store(id, pa)
+ return pa
+}
+
+// Resolve sends a decision for a pending approval. Returns false if not found.
+func (s *ApprovalStore) Resolve(id string, decision ApprovalDecision) bool {
+ val, ok := s.pending.LoadAndDelete(id)
+ if !ok {
+ return false
+ }
+ pa := val.(*PendingApproval)
+ pa.ch <- decision
+ return true
+}
+
+// nextID generates a sequential approval ID.
+func (s *ApprovalStore) nextID() string {
+ n := s.counter.Add(1)
+ return "approval_" + itoa(n)
+}
+
+// itoa converts int64 to string without importing strconv.
+func itoa(n int64) string {
+ if n == 0 {
+ return "0"
+ }
+ var buf [20]byte
+ i := len(buf) - 1
+ for n > 0 {
+ buf[i] = byte('0' + n%10)
+ i--
+ n /= 10
+ }
+ return string(buf[i+1:])
+}
+
+// BuildPreview creates a human-readable preview of a gated tool call.
+func BuildPreview(call ToolCall) map[string]any {
+ preview := make(map[string]any)
+
+ switch call.Name {
+ case "send_email":
+ if to, ok := call.Args["to"].(string); ok {
+ preview["to"] = to
+ }
+ if subj, ok := call.Args["subject"].(string); ok {
+ preview["subject"] = subj
+ }
+ if body, ok := call.Args["body"].(string); ok {
+ if len(body) > 200 {
+ body = body[:200] + "..."
+ }
+ preview["body"] = body
+ }
+ case "create_event":
+ if title, ok := call.Args["title"].(string); ok {
+ preview["title"] = title
+ }
+ if start, ok := call.Args["start_time"].(string); ok {
+ preview["start_time"] = start
+ }
+ if end, ok := call.Args["end_time"].(string); ok {
+ preview["end_time"] = end
+ }
+ if desc, ok := call.Args["description"].(string); ok {
+ preview["description"] = desc
+ }
+ }
+
+ return preview
+}
diff --git a/internal/chat/approval_test.go b/internal/chat/approval_test.go
new file mode 100644
index 0000000..b2f0c2d
--- /dev/null
+++ b/internal/chat/approval_test.go
@@ -0,0 +1,294 @@
+package chat
+
+import (
+ "sync"
+ "testing"
+ "time"
+)
+
+func TestIsGated(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ toolName string
+ want bool
+ }{
+ {"send_email is gated", "send_email", true},
+ {"create_event is gated", "create_event", true},
+ {"list_emails is not gated", "list_emails", false},
+ {"get_event is not gated", "get_event", false},
+ {"empty string is not gated", "", false},
+ {"unknown tool is not gated", "unknown_tool", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ got := IsGated(tt.toolName)
+ if got != tt.want {
+ t.Errorf("IsGated(%q) = %v, want %v", tt.toolName, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestApprovalStore_Create(t *testing.T) {
+ t.Parallel()
+
+ store := NewApprovalStore()
+
+ call1 := ToolCall{Name: "send_email", Args: map[string]any{"to": "test@example.com"}}
+ preview1 := map[string]any{"to": "test@example.com"}
+
+ pa1 := store.Create(call1, preview1)
+
+ if pa1.ID != "approval_1" {
+ t.Errorf("First approval ID = %q, want %q", pa1.ID, "approval_1")
+ }
+ if pa1.Tool != "send_email" {
+ t.Errorf("Tool = %q, want %q", pa1.Tool, "send_email")
+ }
+ if pa1.Preview["to"] != "test@example.com" {
+ t.Errorf("Preview[to] = %v, want %q", pa1.Preview["to"], "test@example.com")
+ }
+
+ // Second approval should have sequential ID
+ call2 := ToolCall{Name: "create_event", Args: map[string]any{"title": "Meeting"}}
+ preview2 := map[string]any{"title": "Meeting"}
+ pa2 := store.Create(call2, preview2)
+
+ if pa2.ID != "approval_2" {
+ t.Errorf("Second approval ID = %q, want %q", pa2.ID, "approval_2")
+ }
+}
+
+func TestApprovalStore_Resolve(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ createID bool
+ resolveID string
+ decision ApprovalDecision
+ want bool
+ }{
+ {
+ name: "resolve existing approval",
+ createID: true,
+ resolveID: "approval_1",
+ decision: ApprovalDecision{Approved: true},
+ want: true,
+ },
+ {
+ name: "resolve non-existent approval",
+ createID: false,
+ resolveID: "approval_999",
+ decision: ApprovalDecision{Approved: false},
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ store := NewApprovalStore()
+
+ if tt.createID {
+ call := ToolCall{Name: "send_email", Args: map[string]any{}}
+ store.Create(call, map[string]any{})
+ }
+
+ got := store.Resolve(tt.resolveID, tt.decision)
+ if got != tt.want {
+ t.Errorf("Resolve(%q) = %v, want %v", tt.resolveID, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestApprovalStore_ResolveAlreadyResolved(t *testing.T) {
+ t.Parallel()
+
+ store := NewApprovalStore()
+ call := ToolCall{Name: "send_email", Args: map[string]any{}}
+ pa := store.Create(call, map[string]any{})
+
+ // Resolve once
+ decision := ApprovalDecision{Approved: true}
+ if !store.Resolve(pa.ID, decision) {
+ t.Fatal("First resolve failed")
+ }
+
+ // Try to resolve again
+ if store.Resolve(pa.ID, decision) {
+ t.Error("Second resolve succeeded, want false for already resolved approval")
+ }
+}
+
+func TestPendingApproval_Wait(t *testing.T) {
+ t.Parallel()
+
+ t.Run("wait returns decision when resolved", func(t *testing.T) {
+ t.Parallel()
+
+ store := NewApprovalStore()
+ call := ToolCall{Name: "send_email", Args: map[string]any{}}
+ pa := store.Create(call, map[string]any{})
+
+ expectedDecision := ApprovalDecision{Approved: true, Reason: "looks good"}
+
+ // Resolve from another goroutine
+ go func() {
+ time.Sleep(50 * time.Millisecond)
+ store.Resolve(pa.ID, expectedDecision)
+ }()
+
+ decision, ok := pa.Wait()
+ if !ok {
+ t.Fatal("Wait returned false, want true")
+ }
+ if decision.Approved != expectedDecision.Approved {
+ t.Errorf("Approved = %v, want %v", decision.Approved, expectedDecision.Approved)
+ }
+ if decision.Reason != expectedDecision.Reason {
+ t.Errorf("Reason = %q, want %q", decision.Reason, expectedDecision.Reason)
+ }
+ })
+
+ t.Run("wait rejects when not resolved", func(t *testing.T) {
+ // This test would take 5 minutes with the real timeout
+ // We can't easily test the timeout without modifying the code
+ // Skip this in normal test runs
+ t.Skip("Timeout test would take 5 minutes")
+ })
+}
+
+func TestPendingApproval_WaitConcurrent(t *testing.T) {
+ t.Parallel()
+
+ store := NewApprovalStore()
+ const numApprovals = 10
+
+ var wg sync.WaitGroup
+ wg.Add(numApprovals)
+
+ for i := 0; i < numApprovals; i++ {
+ go func(idx int) {
+ defer wg.Done()
+
+ call := ToolCall{Name: "send_email", Args: map[string]any{"id": idx}}
+ pa := store.Create(call, map[string]any{})
+
+ // Resolve from another goroutine
+ go func() {
+ time.Sleep(10 * time.Millisecond)
+ store.Resolve(pa.ID, ApprovalDecision{Approved: idx%2 == 0})
+ }()
+
+ decision, ok := pa.Wait()
+ if !ok {
+ t.Errorf("Wait for approval %d failed", idx)
+ }
+ expectedApproval := idx%2 == 0
+ if decision.Approved != expectedApproval {
+ t.Errorf("Approval %d: got %v, want %v", idx, decision.Approved, expectedApproval)
+ }
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestBuildPreview(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ call ToolCall
+ want map[string]any
+ }{
+ {
+ name: "send_email with all fields",
+ call: ToolCall{
+ Name: "send_email",
+ Args: map[string]any{
+ "to": "user@example.com",
+ "subject": "Test Subject",
+ "body": "Short body",
+ },
+ },
+ want: map[string]any{
+ "to": "user@example.com",
+ "subject": "Test Subject",
+ "body": "Short body",
+ },
+ },
+ {
+ name: "send_email with long body truncation",
+ call: ToolCall{
+ Name: "send_email",
+ Args: map[string]any{
+ "to": "user@example.com",
+ "subject": "Test",
+ "body": string(make([]byte, 300)), // 300 null bytes
+ },
+ },
+ want: map[string]any{
+ "to": "user@example.com",
+ "subject": "Test",
+ "body": string(make([]byte, 200)) + "...",
+ },
+ },
+ {
+ name: "create_event with all fields",
+ call: ToolCall{
+ Name: "create_event",
+ Args: map[string]any{
+ "title": "Team Meeting",
+ "start_time": "2026-02-12T10:00:00Z",
+ "end_time": "2026-02-12T11:00:00Z",
+ "description": "Discuss Q1 goals",
+ },
+ },
+ want: map[string]any{
+ "title": "Team Meeting",
+ "start_time": "2026-02-12T10:00:00Z",
+ "end_time": "2026-02-12T11:00:00Z",
+ "description": "Discuss Q1 goals",
+ },
+ },
+ {
+ name: "unknown tool returns empty preview",
+ call: ToolCall{
+ Name: "unknown_tool",
+ Args: map[string]any{"foo": "bar"},
+ },
+ want: map[string]any{},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ got := BuildPreview(tt.call)
+
+ if len(got) != len(tt.want) {
+ t.Errorf("Preview length = %d, want %d", len(got), len(tt.want))
+ }
+
+ for key, wantVal := range tt.want {
+ gotVal, ok := got[key]
+ if !ok {
+ t.Errorf("Preview missing key %q", key)
+ continue
+ }
+ if gotVal != wantVal {
+ t.Errorf("Preview[%q] = %v, want %v", key, gotVal, wantVal)
+ }
+ }
+ })
+ }
+}
diff --git a/internal/chat/executor_operations_test.go b/internal/chat/executor_operations_test.go
new file mode 100644
index 0000000..4466459
--- /dev/null
+++ b/internal/chat/executor_operations_test.go
@@ -0,0 +1,512 @@
+package chat
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "testing"
+
+ "github.com/nylas/cli/internal/adapters/nylas"
+ "github.com/nylas/cli/internal/domain"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// Tests for send email, events, contacts, and folders operations
+
+func TestSendEmail_Success(t *testing.T) {
+ client := nylas.NewMockClient()
+ executor := NewToolExecutor(client, "test-grant")
+
+ client.SendMessageFunc = func(ctx context.Context, grantID string, req *domain.SendMessageRequest) (*domain.Message, error) {
+ assert.Equal(t, "test@example.com", req.To[0].Email)
+ assert.Equal(t, "Test Subject", req.Subject)
+ assert.Equal(t, "Test body", req.Body)
+ return &domain.Message{ID: "sent-123"}, nil
+ }
+
+ result := executor.Execute(context.Background(), ToolCall{
+ Name: "send_email",
+ Args: map[string]any{
+ "to": "test@example.com",
+ "subject": "Test Subject",
+ "body": "Test body",
+ },
+ })
+
+ assert.Empty(t, result.Error)
+
+ jsonData, err := json.Marshal(result.Data)
+ require.NoError(t, err)
+
+ var data map[string]string
+ err = json.Unmarshal(jsonData, &data)
+ require.NoError(t, err)
+
+ assert.Equal(t, "sent-123", data["id"])
+ assert.Equal(t, "sent", data["status"])
+}
+
+func TestSendEmail_MissingParams(t *testing.T) {
+ tests := []struct {
+ name string
+ args map[string]any
+ }{
+ {"missing to", map[string]any{"subject": "Test", "body": "Body"}},
+ {"missing subject", map[string]any{"to": "test@example.com", "body": "Body"}},
+ {"missing body", map[string]any{"to": "test@example.com", "subject": "Test"}},
+ {"empty to", map[string]any{"to": "", "subject": "Test", "body": "Body"}},
+ {"empty subject", map[string]any{"to": "test@example.com", "subject": "", "body": "Body"}},
+ {"empty body", map[string]any{"to": "test@example.com", "subject": "Test", "body": ""}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client := nylas.NewMockClient()
+ executor := NewToolExecutor(client, "test-grant")
+
+ result := executor.Execute(context.Background(), ToolCall{
+ Name: "send_email",
+ Args: tt.args,
+ })
+
+ assert.Contains(t, result.Error, "to, subject, and body are required")
+ })
+ }
+}
+
+func TestSendEmail_Error(t *testing.T) {
+ client := nylas.NewMockClient()
+ executor := NewToolExecutor(client, "test-grant")
+
+ client.SendMessageFunc = func(ctx context.Context, grantID string, req *domain.SendMessageRequest) (*domain.Message, error) {
+ return nil, errors.New("send failed")
+ }
+
+ result := executor.Execute(context.Background(), ToolCall{
+ Name: "send_email",
+ Args: map[string]any{"to": "test@example.com", "subject": "Test", "body": "Body"},
+ })
+
+ assert.Contains(t, result.Error, "send failed")
+}
+
+func TestListEvents_Success(t *testing.T) {
+ tests := []struct {
+ name string
+ args map[string]any
+ wantCalID string
+ wantLimit int
+ events []domain.Event
+ wantEvents int
+ }{
+ {
+ name: "default calendar",
+ args: map[string]any{},
+ wantCalID: "primary",
+ wantLimit: 10,
+ events: []domain.Event{
+ {ID: "evt1", Title: "Meeting", When: domain.EventWhen{StartTime: 1707753600, EndTime: 1707757200}},
+ },
+ wantEvents: 1,
+ },
+ {
+ name: "custom calendar and limit",
+ args: map[string]any{"calendar_id": "work", "limit": float64(5)},
+ wantCalID: "work",
+ wantLimit: 5,
+ events: []domain.Event{
+ {ID: "evt2", Title: "Standup", When: domain.EventWhen{StartTime: 1707753600, EndTime: 1707755400}},
+ },
+ wantEvents: 1,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client := nylas.NewMockClient()
+ executor := NewToolExecutor(client, "test-grant")
+
+ client.GetEventsFunc = func(ctx context.Context, grantID, calendarID string, params *domain.EventQueryParams) ([]domain.Event, error) {
+ assert.Equal(t, tt.wantCalID, calendarID)
+ assert.Equal(t, tt.wantLimit, params.Limit)
+ return tt.events, nil
+ }
+
+ result := executor.Execute(context.Background(), ToolCall{
+ Name: "list_events",
+ Args: tt.args,
+ })
+
+ assert.Empty(t, result.Error)
+
+ data, err := json.Marshal(result.Data)
+ require.NoError(t, err)
+
+ var events []map[string]any
+ err = json.Unmarshal(data, &events)
+ require.NoError(t, err)
+ assert.Len(t, events, tt.wantEvents)
+
+ if tt.wantEvents > 0 {
+ assert.NotEmpty(t, events[0]["id"])
+ assert.NotEmpty(t, events[0]["title"])
+ }
+ })
+ }
+}
+
+func TestListEvents_Error(t *testing.T) {
+ client := nylas.NewMockClient()
+ executor := NewToolExecutor(client, "test-grant")
+
+ client.GetEventsFunc = func(ctx context.Context, grantID, calendarID string, params *domain.EventQueryParams) ([]domain.Event, error) {
+ return nil, errors.New("calendar not found")
+ }
+
+ result := executor.Execute(context.Background(), ToolCall{
+ Name: "list_events",
+ Args: map[string]any{},
+ })
+
+ assert.Contains(t, result.Error, "calendar not found")
+}
+
+func TestCreateEvent_Success(t *testing.T) {
+ client := nylas.NewMockClient()
+ executor := NewToolExecutor(client, "test-grant")
+
+ client.CreateEventFunc = func(ctx context.Context, grantID, calendarID string, req *domain.CreateEventRequest) (*domain.Event, error) {
+ assert.Equal(t, "primary", calendarID)
+ assert.Equal(t, "Team Meeting", req.Title)
+ assert.Equal(t, "Discuss Q1 goals", req.Description)
+ assert.Greater(t, req.When.EndTime, req.When.StartTime)
+ return &domain.Event{ID: "evt-123", Title: "Team Meeting"}, nil
+ }
+
+ result := executor.Execute(context.Background(), ToolCall{
+ Name: "create_event",
+ Args: map[string]any{
+ "title": "Team Meeting",
+ "start_time": "2026-02-12T14:00:00Z",
+ "end_time": "2026-02-12T15:00:00Z",
+ "description": "Discuss Q1 goals",
+ },
+ })
+
+ assert.Empty(t, result.Error)
+
+ jsonData, err := json.Marshal(result.Data)
+ require.NoError(t, err)
+
+ var data map[string]string
+ err = json.Unmarshal(jsonData, &data)
+ require.NoError(t, err)
+
+ assert.Equal(t, "evt-123", data["id"])
+ assert.Equal(t, "Team Meeting", data["title"])
+}
+
+func TestCreateEvent_CustomCalendar(t *testing.T) {
+ client := nylas.NewMockClient()
+ executor := NewToolExecutor(client, "test-grant")
+
+ client.CreateEventFunc = func(ctx context.Context, grantID, calendarID string, req *domain.CreateEventRequest) (*domain.Event, error) {
+ assert.Equal(t, "work-calendar", calendarID)
+ return &domain.Event{ID: "evt-123", Title: "Test"}, nil
+ }
+
+ result := executor.Execute(context.Background(), ToolCall{
+ Name: "create_event",
+ Args: map[string]any{
+ "title": "Test",
+ "start_time": "2026-02-12T14:00:00Z",
+ "end_time": "2026-02-12T15:00:00Z",
+ "calendar_id": "work-calendar",
+ },
+ })
+
+ assert.Empty(t, result.Error)
+}
+
+func TestCreateEvent_MissingParams(t *testing.T) {
+ tests := []struct {
+ name string
+ args map[string]any
+ }{
+ {"missing title", map[string]any{"start_time": "2026-02-12T14:00:00Z", "end_time": "2026-02-12T15:00:00Z"}},
+ {"missing start_time", map[string]any{"title": "Test", "end_time": "2026-02-12T15:00:00Z"}},
+ {"missing end_time", map[string]any{"title": "Test", "start_time": "2026-02-12T14:00:00Z"}},
+ {"empty title", map[string]any{"title": "", "start_time": "2026-02-12T14:00:00Z", "end_time": "2026-02-12T15:00:00Z"}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client := nylas.NewMockClient()
+ executor := NewToolExecutor(client, "test-grant")
+
+ result := executor.Execute(context.Background(), ToolCall{
+ Name: "create_event",
+ Args: tt.args,
+ })
+
+ assert.Contains(t, result.Error, "title, start_time, and end_time are required")
+ })
+ }
+}
+
+func TestCreateEvent_InvalidTimeFormat(t *testing.T) {
+ tests := []struct {
+ name string
+ startTime string
+ endTime string
+ wantError string
+ }{
+ {"invalid start", "invalid-time", "2026-02-12T15:00:00Z", "invalid start_time"},
+ {"invalid end", "2026-02-12T14:00:00Z", "invalid-time", "invalid end_time"},
+ {"wrong format", "2026-02-12 14:00:00", "2026-02-12 15:00:00", "invalid start_time"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client := nylas.NewMockClient()
+ executor := NewToolExecutor(client, "test-grant")
+
+ result := executor.Execute(context.Background(), ToolCall{
+ Name: "create_event",
+ Args: map[string]any{
+ "title": "Test",
+ "start_time": tt.startTime,
+ "end_time": tt.endTime,
+ },
+ })
+
+ assert.Contains(t, result.Error, tt.wantError)
+ })
+ }
+}
+
+func TestCreateEvent_Error(t *testing.T) {
+ client := nylas.NewMockClient()
+ executor := NewToolExecutor(client, "test-grant")
+
+ client.CreateEventFunc = func(ctx context.Context, grantID, calendarID string, req *domain.CreateEventRequest) (*domain.Event, error) {
+ return nil, errors.New("calendar permission denied")
+ }
+
+ result := executor.Execute(context.Background(), ToolCall{
+ Name: "create_event",
+ Args: map[string]any{
+ "title": "Test",
+ "start_time": "2026-02-12T14:00:00Z",
+ "end_time": "2026-02-12T15:00:00Z",
+ },
+ })
+
+ assert.Contains(t, result.Error, "calendar permission denied")
+}
+
+func TestListContacts_Success(t *testing.T) {
+ tests := []struct {
+ name string
+ args map[string]any
+ contacts []domain.Contact
+ wantLimit int
+ wantEmail string
+ wantContacts int
+ }{
+ {
+ name: "default limit",
+ args: map[string]any{},
+ wantLimit: 10,
+ contacts: []domain.Contact{
+ {ID: "c1", GivenName: "John", Surname: "Doe", Emails: []domain.ContactEmail{{Email: "john@example.com"}}},
+ {ID: "c2", GivenName: "Jane", Emails: []domain.ContactEmail{{Email: "jane@example.com"}}},
+ },
+ wantContacts: 2,
+ },
+ {
+ name: "with query",
+ args: map[string]any{"query": "john@example.com", "limit": float64(5)},
+ wantLimit: 5,
+ wantEmail: "john@example.com",
+ contacts: []domain.Contact{
+ {ID: "c1", GivenName: "John", Surname: "Doe", Emails: []domain.ContactEmail{{Email: "john@example.com"}}},
+ },
+ wantContacts: 1,
+ },
+ {
+ name: "no emails",
+ args: map[string]any{},
+ wantLimit: 10,
+ contacts: []domain.Contact{{ID: "c1", GivenName: "Test", Emails: []domain.ContactEmail{}}},
+ wantContacts: 1,
+ },
+ {
+ name: "surname only",
+ args: map[string]any{},
+ wantLimit: 10,
+ contacts: []domain.Contact{
+ {ID: "c1", Surname: "Smith", Emails: []domain.ContactEmail{{Email: "smith@example.com"}}},
+ },
+ wantContacts: 1,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client := nylas.NewMockClient()
+ executor := NewToolExecutor(client, "test-grant")
+
+ client.GetContactsFunc = func(ctx context.Context, grantID string, params *domain.ContactQueryParams) ([]domain.Contact, error) {
+ assert.Equal(t, tt.wantLimit, params.Limit)
+ if tt.wantEmail != "" {
+ assert.Equal(t, tt.wantEmail, params.Email)
+ }
+ return tt.contacts, nil
+ }
+
+ result := executor.Execute(context.Background(), ToolCall{
+ Name: "list_contacts",
+ Args: tt.args,
+ })
+
+ assert.Empty(t, result.Error)
+
+ data, err := json.Marshal(result.Data)
+ require.NoError(t, err)
+
+ var contacts []map[string]any
+ err = json.Unmarshal(data, &contacts)
+ require.NoError(t, err)
+ assert.Len(t, contacts, tt.wantContacts)
+
+ if tt.wantContacts > 0 {
+ assert.NotEmpty(t, contacts[0]["id"])
+ }
+ })
+ }
+}
+
+func TestListContacts_NameFormatting(t *testing.T) {
+ tests := []struct {
+ name string
+ contact domain.Contact
+ wantName string
+ wantEmail string
+ }{
+ {
+ name: "full name",
+ contact: domain.Contact{ID: "c1", GivenName: "John", Surname: "Doe", Emails: []domain.ContactEmail{{Email: "john@example.com"}}},
+ wantName: "John Doe",
+ wantEmail: "john@example.com",
+ },
+ {
+ name: "given name only",
+ contact: domain.Contact{ID: "c2", GivenName: "Jane", Emails: []domain.ContactEmail{{Email: "jane@example.com"}}},
+ wantName: "Jane",
+ wantEmail: "jane@example.com",
+ },
+ {
+ name: "no name",
+ contact: domain.Contact{ID: "c3", Emails: []domain.ContactEmail{{Email: "noname@example.com"}}},
+ wantName: "",
+ wantEmail: "noname@example.com",
+ },
+ {
+ name: "no email",
+ contact: domain.Contact{ID: "c4", GivenName: "Test", Emails: []domain.ContactEmail{}},
+ wantName: "Test",
+ wantEmail: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client := nylas.NewMockClient()
+ executor := NewToolExecutor(client, "test-grant")
+
+ client.GetContactsFunc = func(ctx context.Context, grantID string, params *domain.ContactQueryParams) ([]domain.Contact, error) {
+ return []domain.Contact{tt.contact}, nil
+ }
+
+ result := executor.listContacts(context.Background(), map[string]any{})
+
+ require.Empty(t, result.Error)
+
+ data, err := json.Marshal(result.Data)
+ require.NoError(t, err)
+
+ var contacts []map[string]any
+ err = json.Unmarshal(data, &contacts)
+ require.NoError(t, err)
+ require.Len(t, contacts, 1)
+
+ assert.Equal(t, tt.wantName, contacts[0]["name"])
+ assert.Equal(t, tt.wantEmail, contacts[0]["email"])
+ })
+ }
+}
+
+func TestListContacts_Error(t *testing.T) {
+ client := nylas.NewMockClient()
+ executor := NewToolExecutor(client, "test-grant")
+
+ client.GetContactsFunc = func(ctx context.Context, grantID string, params *domain.ContactQueryParams) ([]domain.Contact, error) {
+ return nil, errors.New("contacts not available")
+ }
+
+ result := executor.Execute(context.Background(), ToolCall{
+ Name: "list_contacts",
+ Args: map[string]any{},
+ })
+
+ assert.Contains(t, result.Error, "contacts not available")
+}
+
+func TestListFolders_Success(t *testing.T) {
+ client := nylas.NewMockClient()
+ executor := NewToolExecutor(client, "test-grant")
+
+ client.GetFoldersFunc = func(ctx context.Context, grantID string) ([]domain.Folder, error) {
+ return []domain.Folder{
+ {ID: "f1", Name: "Inbox"},
+ {ID: "f2", Name: "Sent"},
+ {ID: "f3", Name: "Archive"},
+ }, nil
+ }
+
+ result := executor.Execute(context.Background(), ToolCall{
+ Name: "list_folders",
+ Args: map[string]any{},
+ })
+
+ assert.Empty(t, result.Error)
+
+ data, err := json.Marshal(result.Data)
+ require.NoError(t, err)
+
+ var folders []map[string]any
+ err = json.Unmarshal(data, &folders)
+ require.NoError(t, err)
+ assert.Len(t, folders, 3)
+
+ assert.Equal(t, "f1", folders[0]["id"])
+ assert.Equal(t, "Inbox", folders[0]["name"])
+}
+
+func TestListFolders_Error(t *testing.T) {
+ client := nylas.NewMockClient()
+ executor := NewToolExecutor(client, "test-grant")
+
+ client.GetFoldersFunc = func(ctx context.Context, grantID string) ([]domain.Folder, error) {
+ return nil, errors.New("folders not accessible")
+ }
+
+ result := executor.Execute(context.Background(), ToolCall{
+ Name: "list_folders",
+ Args: map[string]any{},
+ })
+
+ assert.Contains(t, result.Error, "folders not accessible")
+}
diff --git a/internal/chat/executor_test.go b/internal/chat/executor_test.go
new file mode 100644
index 0000000..c38dc71
--- /dev/null
+++ b/internal/chat/executor_test.go
@@ -0,0 +1,438 @@
+package chat
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/nylas/cli/internal/adapters/nylas"
+ "github.com/nylas/cli/internal/domain"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewToolExecutor(t *testing.T) {
+ client := nylas.NewMockClient()
+ grantID := "test-grant"
+
+ executor := NewToolExecutor(client, grantID)
+
+ require.NotNil(t, executor)
+ assert.Equal(t, grantID, executor.grantID)
+ assert.Equal(t, client, executor.client)
+}
+
+func TestExecute_UnknownTool(t *testing.T) {
+ client := nylas.NewMockClient()
+ executor := NewToolExecutor(client, "test-grant")
+
+ result := executor.Execute(context.Background(), ToolCall{
+ Name: "unknown_tool",
+ Args: map[string]any{},
+ })
+
+ assert.Equal(t, "unknown_tool", result.Name)
+ assert.Contains(t, result.Error, "unknown tool: unknown_tool")
+ assert.Nil(t, result.Data)
+}
+
+func TestExecute_Dispatcher(t *testing.T) {
+ tests := []struct {
+ name string
+ toolName string
+ wantDispatch bool
+ }{
+ {"list_emails", "list_emails", true},
+ {"read_email", "read_email", true},
+ {"search_emails", "search_emails", true},
+ {"send_email", "send_email", true},
+ {"list_events", "list_events", true},
+ {"create_event", "create_event", true},
+ {"list_contacts", "list_contacts", true},
+ {"list_folders", "list_folders", true},
+ {"invalid_tool", "invalid_tool", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client := nylas.NewMockClient()
+ executor := NewToolExecutor(client, "test-grant")
+
+ // Set up mock to avoid nil errors
+ client.GetMessagesWithParamsFunc = func(ctx context.Context, grantID string, params *domain.MessageQueryParams) ([]domain.Message, error) {
+ return []domain.Message{}, nil
+ }
+ client.GetMessageFunc = func(ctx context.Context, grantID, messageID string) (*domain.Message, error) {
+ return nil, errors.New("missing id")
+ }
+ client.SendMessageFunc = func(ctx context.Context, grantID string, req *domain.SendMessageRequest) (*domain.Message, error) {
+ return nil, errors.New("missing required fields")
+ }
+ client.GetEventsFunc = func(ctx context.Context, grantID, calendarID string, params *domain.EventQueryParams) ([]domain.Event, error) {
+ return []domain.Event{}, nil
+ }
+ client.CreateEventFunc = func(ctx context.Context, grantID, calendarID string, req *domain.CreateEventRequest) (*domain.Event, error) {
+ return nil, errors.New("missing required fields")
+ }
+ client.GetContactsFunc = func(ctx context.Context, grantID string, params *domain.ContactQueryParams) ([]domain.Contact, error) {
+ return []domain.Contact{}, nil
+ }
+ client.GetFoldersFunc = func(ctx context.Context, grantID string) ([]domain.Folder, error) {
+ return []domain.Folder{}, nil
+ }
+
+ result := executor.Execute(context.Background(), ToolCall{
+ Name: tt.toolName,
+ Args: map[string]any{},
+ })
+
+ assert.Equal(t, tt.toolName, result.Name)
+
+ if !tt.wantDispatch {
+ assert.Contains(t, result.Error, "unknown tool")
+ }
+ })
+ }
+}
+
+func TestListEmails_Success(t *testing.T) {
+ now := time.Now()
+ tests := []struct {
+ name string
+ args map[string]any
+ messages []domain.Message
+ wantLen int
+ }{
+ {
+ name: "default limit",
+ args: map[string]any{},
+ messages: []domain.Message{
+ {ID: "msg1", Subject: "Test 1", Snippet: "snippet1", Date: now, Unread: true, From: []domain.EmailParticipant{{Email: "test@example.com", Name: "Test User"}}},
+ {ID: "msg2", Subject: "Test 2", Snippet: "snippet2", Date: now, Unread: false, From: []domain.EmailParticipant{{Email: "other@example.com"}}},
+ },
+ wantLen: 2,
+ },
+ {
+ name: "with limit",
+ args: map[string]any{"limit": float64(5)},
+ messages: []domain.Message{
+ {ID: "msg1", Subject: "Test", Snippet: "snippet", Date: now, From: []domain.EmailParticipant{{Email: "test@example.com"}}},
+ },
+ wantLen: 1,
+ },
+ {
+ name: "with filters",
+ args: map[string]any{
+ "subject": "Meeting",
+ "from": "boss@example.com",
+ "unread": true,
+ },
+ messages: []domain.Message{
+ {ID: "msg1", Subject: "Meeting Notes", Snippet: "snippet", Date: now, Unread: true, From: []domain.EmailParticipant{{Email: "boss@example.com", Name: "Boss"}}},
+ },
+ wantLen: 1,
+ },
+ {
+ name: "empty from array",
+ args: map[string]any{},
+ messages: []domain.Message{{ID: "msg1", Subject: "Test", Date: now, From: []domain.EmailParticipant{}}},
+ wantLen: 1,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client := nylas.NewMockClient()
+ executor := NewToolExecutor(client, "test-grant")
+
+ client.GetMessagesWithParamsFunc = func(ctx context.Context, grantID string, params *domain.MessageQueryParams) ([]domain.Message, error) {
+ return tt.messages, nil
+ }
+
+ result := executor.Execute(context.Background(), ToolCall{
+ Name: "list_emails",
+ Args: tt.args,
+ })
+
+ assert.Empty(t, result.Error)
+ assert.NotNil(t, result.Data)
+
+ // The executor returns a custom struct slice, not []any
+ // We can verify it's not nil and use JSON to validate structure
+ data, err := json.Marshal(result.Data)
+ require.NoError(t, err)
+
+ var emails []map[string]any
+ err = json.Unmarshal(data, &emails)
+ require.NoError(t, err)
+ assert.Len(t, emails, tt.wantLen)
+
+ if tt.wantLen > 0 {
+ assert.NotEmpty(t, emails[0]["id"])
+ assert.NotEmpty(t, emails[0]["date"])
+ }
+ })
+ }
+}
+
+func TestListEmails_FromFormatting(t *testing.T) {
+ now := time.Now()
+ tests := []struct {
+ name string
+ from []domain.EmailParticipant
+ wantFrom string
+ }{
+ {
+ name: "with name",
+ from: []domain.EmailParticipant{{Email: "test@example.com", Name: "Test User"}},
+ wantFrom: "Test User ",
+ },
+ {
+ name: "without name",
+ from: []domain.EmailParticipant{{Email: "test@example.com"}},
+ wantFrom: "test@example.com",
+ },
+ {
+ name: "empty from",
+ from: []domain.EmailParticipant{},
+ wantFrom: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client := nylas.NewMockClient()
+ executor := NewToolExecutor(client, "test-grant")
+
+ client.GetMessagesWithParamsFunc = func(ctx context.Context, grantID string, params *domain.MessageQueryParams) ([]domain.Message, error) {
+ return []domain.Message{{ID: "msg1", Subject: "Test", Date: now, From: tt.from}}, nil
+ }
+
+ result := executor.listEmails(context.Background(), map[string]any{})
+
+ require.Empty(t, result.Error)
+
+ data, err := json.Marshal(result.Data)
+ require.NoError(t, err)
+
+ var emails []map[string]any
+ err = json.Unmarshal(data, &emails)
+ require.NoError(t, err)
+ require.Len(t, emails, 1)
+
+ assert.Equal(t, tt.wantFrom, emails[0]["from"])
+ })
+ }
+}
+
+func TestListEmails_Error(t *testing.T) {
+ client := nylas.NewMockClient()
+ executor := NewToolExecutor(client, "test-grant")
+
+ client.GetMessagesWithParamsFunc = func(ctx context.Context, grantID string, params *domain.MessageQueryParams) ([]domain.Message, error) {
+ return nil, errors.New("API error")
+ }
+
+ result := executor.Execute(context.Background(), ToolCall{
+ Name: "list_emails",
+ Args: map[string]any{},
+ })
+
+ assert.Equal(t, "list_emails", result.Name)
+ assert.Contains(t, result.Error, "API error")
+ assert.Nil(t, result.Data)
+}
+
+func TestReadEmail_Success(t *testing.T) {
+ now := time.Now()
+ tests := []struct {
+ name string
+ message *domain.Message
+ wantLen int
+ }{
+ {
+ name: "normal email",
+ message: &domain.Message{
+ ID: "msg1",
+ Subject: "Test Subject",
+ Body: "Email body",
+ Date: now,
+ From: []domain.EmailParticipant{{Email: "sender@example.com", Name: "Sender"}},
+ To: []domain.EmailParticipant{{Email: "recipient@example.com"}},
+ },
+ wantLen: len("Email body"),
+ },
+ {
+ name: "long body truncation",
+ message: &domain.Message{
+ ID: "msg2",
+ Subject: "Long Email",
+ Body: strings.Repeat("a", 6000),
+ Date: now,
+ From: []domain.EmailParticipant{{Email: "sender@example.com"}},
+ },
+ wantLen: 5000 + len("\n... [truncated]"),
+ },
+ {
+ name: "exactly 5000 chars",
+ message: &domain.Message{
+ ID: "msg3",
+ Subject: "Edge Case",
+ Body: strings.Repeat("a", 5000),
+ Date: now,
+ From: []domain.EmailParticipant{{Email: "sender@example.com"}},
+ },
+ wantLen: 5000,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client := nylas.NewMockClient()
+ executor := NewToolExecutor(client, "test-grant")
+
+ client.GetMessageFunc = func(ctx context.Context, grantID, messageID string) (*domain.Message, error) {
+ return tt.message, nil
+ }
+
+ result := executor.Execute(context.Background(), ToolCall{
+ Name: "read_email",
+ Args: map[string]any{"id": "msg1"},
+ })
+
+ assert.Empty(t, result.Error)
+ require.NotNil(t, result.Data)
+
+ data, err := json.Marshal(result.Data)
+ require.NoError(t, err)
+
+ var email map[string]any
+ err = json.Unmarshal(data, &email)
+ require.NoError(t, err)
+
+ assert.Equal(t, tt.message.ID, email["id"])
+ assert.Equal(t, tt.message.Subject, email["subject"])
+ assert.Len(t, email["body"].(string), tt.wantLen)
+
+ if len(tt.message.Body) > 5000 {
+ assert.True(t, strings.HasSuffix(email["body"].(string), "\n... [truncated]"))
+ }
+ })
+ }
+}
+
+func TestReadEmail_MissingID(t *testing.T) {
+ tests := []struct {
+ name string
+ args map[string]any
+ }{
+ {"no id", map[string]any{}},
+ {"empty id", map[string]any{"id": ""}},
+ {"wrong type", map[string]any{"id": 123}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client := nylas.NewMockClient()
+ executor := NewToolExecutor(client, "test-grant")
+
+ result := executor.Execute(context.Background(), ToolCall{
+ Name: "read_email",
+ Args: tt.args,
+ })
+
+ assert.Equal(t, "read_email", result.Name)
+ assert.Contains(t, result.Error, "id parameter is required")
+ assert.Nil(t, result.Data)
+ })
+ }
+}
+
+func TestReadEmail_Error(t *testing.T) {
+ client := nylas.NewMockClient()
+ executor := NewToolExecutor(client, "test-grant")
+
+ client.GetMessageFunc = func(ctx context.Context, grantID, messageID string) (*domain.Message, error) {
+ return nil, errors.New("message not found")
+ }
+
+ result := executor.Execute(context.Background(), ToolCall{
+ Name: "read_email",
+ Args: map[string]any{"id": "nonexistent"},
+ })
+
+ assert.Contains(t, result.Error, "message not found")
+}
+
+func TestSearchEmails_Success(t *testing.T) {
+ now := time.Now()
+ client := nylas.NewMockClient()
+ executor := NewToolExecutor(client, "test-grant")
+
+ client.GetMessagesWithParamsFunc = func(ctx context.Context, grantID string, params *domain.MessageQueryParams) ([]domain.Message, error) {
+ assert.Equal(t, "budget report", params.SearchQuery)
+ assert.Equal(t, 20, params.Limit)
+ return []domain.Message{
+ {ID: "msg1", Subject: "Budget Report", Snippet: "Q1 budget", Date: now, From: []domain.EmailParticipant{{Email: "finance@example.com"}}},
+ }, nil
+ }
+
+ result := executor.Execute(context.Background(), ToolCall{
+ Name: "search_emails",
+ Args: map[string]any{"query": "budget report", "limit": float64(20)},
+ })
+
+ assert.Empty(t, result.Error)
+
+ data, err := json.Marshal(result.Data)
+ require.NoError(t, err)
+
+ var emails []map[string]any
+ err = json.Unmarshal(data, &emails)
+ require.NoError(t, err)
+ assert.Len(t, emails, 1)
+}
+
+func TestSearchEmails_MissingQuery(t *testing.T) {
+ tests := []struct {
+ name string
+ args map[string]any
+ }{
+ {"no query", map[string]any{}},
+ {"empty query", map[string]any{"query": ""}},
+ {"wrong type", map[string]any{"query": 123}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client := nylas.NewMockClient()
+ executor := NewToolExecutor(client, "test-grant")
+
+ result := executor.Execute(context.Background(), ToolCall{
+ Name: "search_emails",
+ Args: tt.args,
+ })
+
+ assert.Contains(t, result.Error, "query parameter is required")
+ })
+ }
+}
+
+func TestSearchEmails_Error(t *testing.T) {
+ client := nylas.NewMockClient()
+ executor := NewToolExecutor(client, "test-grant")
+
+ client.GetMessagesWithParamsFunc = func(ctx context.Context, grantID string, params *domain.MessageQueryParams) ([]domain.Message, error) {
+ return nil, errors.New("search failed")
+ }
+
+ result := executor.Execute(context.Background(), ToolCall{
+ Name: "search_emails",
+ Args: map[string]any{"query": "test"},
+ })
+
+ assert.Contains(t, result.Error, "search failed")
+}
diff --git a/internal/chat/handlers.go b/internal/chat/handlers.go
index b0085b2..6742338 100644
--- a/internal/chat/handlers.go
+++ b/internal/chat/handlers.go
@@ -105,21 +105,39 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
for i := range maxToolIterations {
_ = i
- response, err := agent.Run(ctx, prompt)
- if err != nil {
- sendSSE(w, flusher, "error", map[string]string{"error": err.Error()})
- return
+ // Use streaming when supported
+ var response string
+ if agent.SupportsStreaming() {
+ var streamErr error
+ response, streamErr = agent.RunStreaming(ctx, prompt, func(token string) {
+ sendSSE(w, flusher, "token", map[string]string{"text": token})
+ })
+ if streamErr != nil {
+ sendSSE(w, flusher, "error", map[string]string{"error": streamErr.Error()})
+ return
+ }
+ } else {
+ var runErr error
+ response, runErr = agent.Run(ctx, prompt)
+ if runErr != nil {
+ sendSSE(w, flusher, "error", map[string]string{"error": runErr.Error()})
+ return
+ }
}
// Parse tool calls from response
toolCalls, textResponse := ParseToolCalls(response)
if len(toolCalls) == 0 {
- // No tool calls - this is the final response
+ // No tool calls — streamed content is the final response
+ sendSSE(w, flusher, "stream_end", nil)
finalResponse = textResponse
break
}
+ // Tool calls found — tell frontend to discard streamed tokens
+ sendSSE(w, flusher, "stream_discard", nil)
+
// Execute tool calls and collect results
for _, call := range toolCalls {
sendSSE(w, flusher, "tool_call", map[string]any{
@@ -127,6 +145,49 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
"args": call.Args,
})
+ // Check if this tool requires approval
+ if IsGated(call.Name) {
+ preview := BuildPreview(call)
+ pa := s.approvals.Create(call, preview)
+
+ sendSSE(w, flusher, "approval_required", map[string]any{
+ "approval_id": pa.ID,
+ "tool": call.Name,
+ "preview": preview,
+ })
+
+ // Block until user approves/rejects or timeout
+ decision, _ := pa.Wait()
+
+ sendSSE(w, flusher, "approval_resolved", map[string]any{
+ "approval_id": pa.ID,
+ "approved": decision.Approved,
+ "reason": decision.Reason,
+ })
+
+ if !decision.Approved {
+ // Inject rejection as tool result
+ result := ToolResult{
+ Name: call.Name,
+ Error: "Action rejected by user: " + decision.Reason,
+ }
+ sendSSE(w, flusher, "tool_result", map[string]any{
+ "name": result.Name,
+ "error": result.Error,
+ })
+
+ resultJSON, _ := json.Marshal(result)
+ _ = s.memory.AddMessage(conv.ID, Message{
+ Role: "tool_result",
+ Name: result.Name,
+ Content: string(resultJSON),
+ })
+
+ prompt += "\n" + FormatToolResult(result)
+ continue
+ }
+ }
+
result := s.executor.Execute(ctx, call)
sendSSE(w, flusher, "tool_result", map[string]any{
diff --git a/internal/chat/handlers_approval.go b/internal/chat/handlers_approval.go
new file mode 100644
index 0000000..fdbedd6
--- /dev/null
+++ b/internal/chat/handlers_approval.go
@@ -0,0 +1,72 @@
+package chat
+
+import (
+ "net/http"
+
+ "github.com/nylas/cli/internal/httputil"
+)
+
+// approvalRequest is the body for approve/reject endpoints.
+type approvalRequest struct {
+ ApprovalID string `json:"approval_id"`
+ Reason string `json:"reason,omitempty"`
+}
+
+// handleApprove approves a pending tool call.
+// POST /api/chat/approve
+func (s *Server) handleApprove(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ var req approvalRequest
+ if err := httputil.DecodeJSON(w, r, &req); err != nil {
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.ApprovalID == "" {
+ http.Error(w, "approval_id is required", http.StatusBadRequest)
+ return
+ }
+
+ if !s.approvals.Resolve(req.ApprovalID, ApprovalDecision{Approved: true}) {
+ http.Error(w, "approval not found or already resolved", http.StatusNotFound)
+ return
+ }
+
+ httputil.WriteJSON(w, http.StatusOK, map[string]string{"status": "approved"})
+}
+
+// handleReject rejects a pending tool call.
+// POST /api/chat/reject
+func (s *Server) handleReject(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ var req approvalRequest
+ if err := httputil.DecodeJSON(w, r, &req); err != nil {
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.ApprovalID == "" {
+ http.Error(w, "approval_id is required", http.StatusBadRequest)
+ return
+ }
+
+ reason := req.Reason
+ if reason == "" {
+ reason = "rejected by user"
+ }
+
+ if !s.approvals.Resolve(req.ApprovalID, ApprovalDecision{Approved: false, Reason: reason}) {
+ http.Error(w, "approval not found or already resolved", http.StatusNotFound)
+ return
+ }
+
+ httputil.WriteJSON(w, http.StatusOK, map[string]string{"status": "rejected"})
+}
diff --git a/internal/chat/handlers_approval_test.go b/internal/chat/handlers_approval_test.go
new file mode 100644
index 0000000..cea8988
--- /dev/null
+++ b/internal/chat/handlers_approval_test.go
@@ -0,0 +1,232 @@
+package chat
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestHandleApprove(t *testing.T) {
+ tests := []struct {
+ name string
+ method string
+ body map[string]any
+ setupApproval bool
+ approvalID string
+ expectedStatus int
+ expectedBody string
+ }{
+ {
+ name: "wrong method returns 405",
+ method: http.MethodGet,
+ body: map[string]any{"approval_id": "approval_1"},
+ setupApproval: false,
+ expectedStatus: http.StatusMethodNotAllowed,
+ expectedBody: "Method not allowed",
+ },
+ {
+ name: "missing approval_id returns 400",
+ method: http.MethodPost,
+ body: map[string]any{},
+ setupApproval: false,
+ expectedStatus: http.StatusBadRequest,
+ expectedBody: "approval_id is required",
+ },
+ {
+ name: "empty approval_id returns 400",
+ method: http.MethodPost,
+ body: map[string]any{"approval_id": ""},
+ setupApproval: false,
+ expectedStatus: http.StatusBadRequest,
+ expectedBody: "approval_id is required",
+ },
+ {
+ name: "unknown approval_id returns 404",
+ method: http.MethodPost,
+ body: map[string]any{"approval_id": "nonexistent"},
+ setupApproval: false,
+ expectedStatus: http.StatusNotFound,
+ expectedBody: "approval not found or already resolved",
+ },
+ {
+ name: "valid approval returns 200",
+ method: http.MethodPost,
+ body: map[string]any{"approval_id": "approval_1"},
+ setupApproval: true,
+ approvalID: "approval_1",
+ expectedStatus: http.StatusOK,
+ expectedBody: `{"status":"approved"}`,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Create server with approval store
+ s := &Server{
+ approvals: NewApprovalStore(),
+ }
+
+ // Setup pending approval if needed
+ if tt.setupApproval {
+ pa := s.approvals.Create(
+ ToolCall{Name: "send_email", Args: map[string]any{"to": "test@example.com"}},
+ map[string]any{"to": "test@example.com"},
+ )
+ require.Equal(t, tt.approvalID, pa.ID)
+
+ // Start goroutine to receive decision
+ go func() {
+ decision, ok := pa.Wait()
+ assert.True(t, ok)
+ assert.True(t, decision.Approved)
+ assert.Empty(t, decision.Reason)
+ }()
+ }
+
+ // Create request
+ bodyBytes, err := json.Marshal(tt.body)
+ require.NoError(t, err)
+
+ req := httptest.NewRequest(tt.method, "/api/chat/approve", bytes.NewReader(bodyBytes))
+ req.Header.Set("Content-Type", "application/json")
+
+ // Record response
+ w := httptest.NewRecorder()
+
+ // Call handler
+ s.handleApprove(w, req)
+
+ // Verify response
+ assert.Equal(t, tt.expectedStatus, w.Code)
+
+ if tt.expectedStatus == http.StatusOK {
+ // JSON response
+ assert.JSONEq(t, tt.expectedBody, w.Body.String())
+ } else {
+ // Plain text error
+ assert.Contains(t, w.Body.String(), tt.expectedBody)
+ }
+ })
+ }
+}
+
+func TestHandleReject(t *testing.T) {
+ tests := []struct {
+ name string
+ method string
+ body map[string]any
+ setupApproval bool
+ approvalID string
+ expectedStatus int
+ expectedBody string
+ expectedReason string
+ }{
+ {
+ name: "wrong method returns 405",
+ method: http.MethodGet,
+ body: map[string]any{"approval_id": "approval_1"},
+ setupApproval: false,
+ expectedStatus: http.StatusMethodNotAllowed,
+ expectedBody: "Method not allowed",
+ },
+ {
+ name: "missing approval_id returns 400",
+ method: http.MethodPost,
+ body: map[string]any{},
+ setupApproval: false,
+ expectedStatus: http.StatusBadRequest,
+ expectedBody: "approval_id is required",
+ },
+ {
+ name: "empty approval_id returns 400",
+ method: http.MethodPost,
+ body: map[string]any{"approval_id": ""},
+ setupApproval: false,
+ expectedStatus: http.StatusBadRequest,
+ expectedBody: "approval_id is required",
+ },
+ {
+ name: "unknown approval_id returns 404",
+ method: http.MethodPost,
+ body: map[string]any{"approval_id": "nonexistent"},
+ setupApproval: false,
+ expectedStatus: http.StatusNotFound,
+ expectedBody: "approval not found or already resolved",
+ },
+ {
+ name: "valid reject with custom reason",
+ method: http.MethodPost,
+ body: map[string]any{"approval_id": "approval_1", "reason": "Not authorized"},
+ setupApproval: true,
+ approvalID: "approval_1",
+ expectedStatus: http.StatusOK,
+ expectedBody: `{"status":"rejected"}`,
+ expectedReason: "Not authorized",
+ },
+ {
+ name: "valid reject with default reason",
+ method: http.MethodPost,
+ body: map[string]any{"approval_id": "approval_1"},
+ setupApproval: true,
+ approvalID: "approval_1",
+ expectedStatus: http.StatusOK,
+ expectedBody: `{"status":"rejected"}`,
+ expectedReason: "rejected by user",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Create server with approval store
+ s := &Server{
+ approvals: NewApprovalStore(),
+ }
+
+ // Setup pending approval if needed
+ if tt.setupApproval {
+ pa := s.approvals.Create(
+ ToolCall{Name: "create_event", Args: map[string]any{"title": "Meeting"}},
+ map[string]any{"title": "Meeting"},
+ )
+ require.Equal(t, tt.approvalID, pa.ID)
+
+ // Start goroutine to receive decision
+ go func() {
+ decision, ok := pa.Wait()
+ assert.True(t, ok)
+ assert.False(t, decision.Approved)
+ assert.Equal(t, tt.expectedReason, decision.Reason)
+ }()
+ }
+
+ // Create request
+ bodyBytes, err := json.Marshal(tt.body)
+ require.NoError(t, err)
+
+ req := httptest.NewRequest(tt.method, "/api/chat/reject", bytes.NewReader(bodyBytes))
+ req.Header.Set("Content-Type", "application/json")
+
+ // Record response
+ w := httptest.NewRecorder()
+
+ // Call handler
+ s.handleReject(w, req)
+
+ // Verify response
+ assert.Equal(t, tt.expectedStatus, w.Code)
+
+ if tt.expectedStatus == http.StatusOK {
+ // JSON response
+ assert.JSONEq(t, tt.expectedBody, w.Body.String())
+ } else {
+ // Plain text error
+ assert.Contains(t, w.Body.String(), tt.expectedBody)
+ }
+ })
+ }
+}
diff --git a/internal/chat/handlers_cmd.go b/internal/chat/handlers_cmd.go
new file mode 100644
index 0000000..b11a638
--- /dev/null
+++ b/internal/chat/handlers_cmd.go
@@ -0,0 +1,130 @@
+package chat
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strconv"
+
+ "github.com/nylas/cli/internal/httputil"
+)
+
+// commandRequest is the body for POST /api/command.
+type commandRequest struct {
+ Name string `json:"name"`
+ Args string `json:"args"`
+ ConversationID string `json:"conversation_id"`
+}
+
+// commandResponse is the JSON response from a slash command.
+type commandResponse struct {
+ Content string `json:"content,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// handleCommand dispatches slash commands.
+// POST /api/command
+func (s *Server) handleCommand(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ var req commandRequest
+ if err := httputil.DecodeJSON(w, r, &req); err != nil {
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.Name == "" {
+ http.Error(w, "command name is required", http.StatusBadRequest)
+ return
+ }
+
+ var resp commandResponse
+
+ switch req.Name {
+ case "status":
+ resp = s.cmdStatus()
+ case "email":
+ resp = s.cmdEmail(r.Context(), req.Args)
+ case "calendar":
+ resp = s.cmdCalendar(r.Context(), req.Args)
+ case "contacts":
+ resp = s.cmdContacts(r.Context(), req.Args)
+ default:
+ resp = commandResponse{Error: "unknown command: " + req.Name}
+ }
+
+ httputil.WriteJSON(w, http.StatusOK, resp)
+}
+
+// cmdStatus returns current session info.
+func (s *Server) cmdStatus() commandResponse {
+ agent := s.ActiveAgent()
+ convs, _ := s.memory.List()
+
+ return commandResponse{
+ Content: fmt.Sprintf("**Status**\n- Agent: `%s`\n- Grant ID: `%s`\n- Conversations: %d",
+ agent.String(), s.grantID, len(convs)),
+ }
+}
+
+// cmdEmail runs a quick email lookup.
+func (s *Server) cmdEmail(ctx context.Context, query string) commandResponse {
+ args := map[string]any{"limit": float64(5)}
+ if query != "" {
+ // Use as search query
+ result := s.executor.Execute(ctx, ToolCall{Name: "search_emails", Args: map[string]any{
+ "query": query,
+ "limit": float64(5),
+ }})
+ return toolResultToResponse(result, "emails")
+ }
+
+ result := s.executor.Execute(ctx, ToolCall{Name: "list_emails", Args: args})
+ return toolResultToResponse(result, "emails")
+}
+
+// cmdCalendar lists upcoming events.
+func (s *Server) cmdCalendar(ctx context.Context, daysStr string) commandResponse {
+ limit := float64(10)
+ if daysStr != "" {
+ if n, err := strconv.Atoi(daysStr); err == nil && n > 0 {
+ limit = float64(n)
+ }
+ }
+
+ result := s.executor.Execute(ctx, ToolCall{Name: "list_events", Args: map[string]any{
+ "limit": limit,
+ }})
+ return toolResultToResponse(result, "events")
+}
+
+// cmdContacts searches contacts.
+func (s *Server) cmdContacts(ctx context.Context, query string) commandResponse {
+ args := map[string]any{"limit": float64(10)}
+ if query != "" {
+ args["query"] = query
+ }
+
+ result := s.executor.Execute(ctx, ToolCall{Name: "list_contacts", Args: args})
+ return toolResultToResponse(result, "contacts")
+}
+
+// toolResultToResponse converts a ToolResult into a command response.
+func toolResultToResponse(result ToolResult, label string) commandResponse {
+ if result.Error != "" {
+ return commandResponse{Error: result.Error}
+ }
+
+ data, err := json.MarshalIndent(result.Data, "", " ")
+ if err != nil {
+ return commandResponse{Error: "failed to format results"}
+ }
+
+ return commandResponse{
+ Content: fmt.Sprintf("**%s results:**\n```json\n%s\n```", label, string(data)),
+ }
+}
diff --git a/internal/chat/handlers_cmd_test.go b/internal/chat/handlers_cmd_test.go
new file mode 100644
index 0000000..383a61a
--- /dev/null
+++ b/internal/chat/handlers_cmd_test.go
@@ -0,0 +1,270 @@
+package chat
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/nylas/cli/internal/adapters/nylas"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// setupTestServer creates a test server with mocked dependencies.
+func setupTestServer(t *testing.T) *Server {
+ t.Helper()
+ memory := setupMemoryStore(t)
+ mockClient := nylas.NewMockClient()
+ agent := &Agent{Type: AgentClaude, Version: "1.0"}
+ executor := NewToolExecutor(mockClient, "test-grant")
+
+ return &Server{
+ agent: agent,
+ grantID: "test-grant",
+ memory: memory,
+ executor: executor,
+ }
+}
+
+func TestHandleCommand(t *testing.T) {
+ tests := []struct {
+ name string
+ method string
+ body commandRequest
+ wantStatus int
+ wantErrMessage string
+ }{
+ {
+ name: "rejects non-POST method",
+ method: http.MethodGet,
+ body: commandRequest{},
+ wantStatus: http.StatusMethodNotAllowed,
+ wantErrMessage: "Method not allowed",
+ },
+ {
+ name: "rejects missing command name",
+ method: http.MethodPost,
+ body: commandRequest{Name: ""},
+ wantStatus: http.StatusBadRequest,
+ wantErrMessage: "command name is required",
+ },
+ {
+ name: "handles unknown command",
+ method: http.MethodPost,
+ body: commandRequest{Name: "invalid"},
+ wantStatus: http.StatusOK,
+ },
+ {
+ name: "handles status command",
+ method: http.MethodPost,
+ body: commandRequest{Name: "status"},
+ wantStatus: http.StatusOK,
+ },
+ {
+ name: "handles email command",
+ method: http.MethodPost,
+ body: commandRequest{Name: "email", Args: "test"},
+ wantStatus: http.StatusOK,
+ },
+ {
+ name: "handles calendar command",
+ method: http.MethodPost,
+ body: commandRequest{Name: "calendar", Args: "7"},
+ wantStatus: http.StatusOK,
+ },
+ {
+ name: "handles contacts command",
+ method: http.MethodPost,
+ body: commandRequest{Name: "contacts", Args: "john"},
+ wantStatus: http.StatusOK,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ server := setupTestServer(t)
+
+ // Create request
+ var body []byte
+ if tt.method == http.MethodPost {
+ body, _ = json.Marshal(tt.body)
+ }
+ req := httptest.NewRequest(tt.method, "/api/command", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ // Execute
+ server.handleCommand(w, req)
+
+ // Verify status code
+ assert.Equal(t, tt.wantStatus, w.Code)
+
+ // Verify error message for non-200 responses
+ if tt.wantErrMessage != "" {
+ assert.Contains(t, w.Body.String(), tt.wantErrMessage)
+ }
+
+ // Verify successful responses have proper structure
+ if tt.wantStatus == http.StatusOK {
+ var resp commandResponse
+ err := json.NewDecoder(w.Body).Decode(&resp)
+ require.NoError(t, err)
+
+ // Unknown command returns error
+ if tt.body.Name == "invalid" {
+ assert.Contains(t, resp.Error, "unknown command")
+ } else {
+ // Valid commands return content or data
+ assert.True(t, resp.Content != "" || resp.Error == "")
+ }
+ }
+ })
+ }
+}
+
+func TestCmdStatus(t *testing.T) {
+ tests := []struct {
+ name string
+ agent *Agent
+ grantID string
+ convCount int
+ }{
+ {
+ name: "returns status with claude agent",
+ agent: &Agent{Type: AgentClaude, Version: "1.0"},
+ grantID: "grant-123",
+ convCount: 0,
+ },
+ {
+ name: "returns status with ollama agent",
+ agent: &Agent{Type: AgentOllama, Model: "mistral"},
+ grantID: "grant-456",
+ convCount: 3,
+ },
+ {
+ name: "handles empty grant ID",
+ agent: &Agent{Type: AgentClaude},
+ grantID: "",
+ convCount: 5,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ memory := setupMemoryStore(t)
+ // Create conversations
+ for i := 0; i < tt.convCount; i++ {
+ _, _ = memory.Create("test-agent")
+ }
+
+ server := &Server{
+ agent: tt.agent,
+ grantID: tt.grantID,
+ memory: memory,
+ }
+
+ resp := server.cmdStatus()
+
+ // Verify response structure
+ require.NotEmpty(t, resp.Content)
+ assert.Contains(t, resp.Content, "Status")
+ assert.Contains(t, resp.Content, "Agent:")
+ assert.Contains(t, resp.Content, "Grant ID:")
+ assert.Contains(t, resp.Content, "Conversations:")
+ assert.Empty(t, resp.Error)
+
+ // Verify agent type is included
+ assert.Contains(t, resp.Content, string(tt.agent.Type))
+
+ // Verify grant ID is included
+ if tt.grantID != "" {
+ assert.Contains(t, resp.Content, tt.grantID)
+ }
+ })
+ }
+}
+
+func TestToolResultToResponse(t *testing.T) {
+ tests := []struct {
+ name string
+ result ToolResult
+ label string
+ wantContent bool
+ wantError string
+ }{
+ {
+ name: "converts result with data",
+ result: ToolResult{
+ Name: "test",
+ Data: map[string]string{"key": "value"},
+ },
+ label: "emails",
+ wantContent: true,
+ },
+ {
+ name: "converts result with array data",
+ result: ToolResult{
+ Name: "test",
+ Data: []map[string]string{{"id": "1"}, {"id": "2"}},
+ },
+ label: "events",
+ wantContent: true,
+ },
+ {
+ name: "converts result with error",
+ result: ToolResult{
+ Name: "test",
+ Error: "api error occurred",
+ },
+ label: "contacts",
+ wantError: "api error occurred",
+ },
+ {
+ name: "handles nil data",
+ result: ToolResult{
+ Name: "test",
+ Data: nil,
+ },
+ label: "results",
+ wantContent: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ resp := toolResultToResponse(tt.result, tt.label)
+
+ if tt.wantError != "" {
+ assert.Equal(t, tt.wantError, resp.Error)
+ assert.Empty(t, resp.Content)
+ } else if tt.wantContent {
+ assert.NotEmpty(t, resp.Content)
+ assert.Contains(t, resp.Content, tt.label)
+ assert.Contains(t, resp.Content, "```json")
+ assert.Empty(t, resp.Error)
+
+ // Verify JSON formatting
+ if tt.result.Data != nil {
+ dataJSON, _ := json.MarshalIndent(tt.result.Data, "", " ")
+ assert.Contains(t, resp.Content, string(dataJSON))
+ }
+ }
+ })
+ }
+
+ t.Run("handles marshal failure gracefully", func(t *testing.T) {
+ // Create unmarshalable data (channels can't be marshaled)
+ ch := make(chan int)
+ result := ToolResult{
+ Name: "test",
+ Data: ch,
+ }
+
+ resp := toolResultToResponse(result, "test")
+
+ assert.Empty(t, resp.Content)
+ assert.Equal(t, "failed to format results", resp.Error)
+ })
+}
diff --git a/internal/chat/handlers_conv_test.go b/internal/chat/handlers_conv_test.go
new file mode 100644
index 0000000..c7670cc
--- /dev/null
+++ b/internal/chat/handlers_conv_test.go
@@ -0,0 +1,569 @@
+package chat
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// setupServerWithMemory creates a test server with memory store.
+func setupServerWithMemory(t *testing.T) *Server {
+ t.Helper()
+ memory := setupMemoryStore(t)
+ agent := &Agent{Type: AgentClaude, Version: "1.0"}
+ return &Server{
+ agent: agent,
+ memory: memory,
+ }
+}
+
+func TestHandleConversations(t *testing.T) {
+ tests := []struct {
+ name string
+ method string
+ setupConvs int // number of conversations to create before request
+ expectedStatus int
+ checkResponse func(t *testing.T, body string)
+ }{
+ {
+ name: "GET returns empty array when no conversations",
+ method: http.MethodGet,
+ setupConvs: 0,
+ expectedStatus: http.StatusOK,
+ checkResponse: func(t *testing.T, body string) {
+ var convs []ConversationSummary
+ err := json.Unmarshal([]byte(body), &convs)
+ require.NoError(t, err)
+ assert.Empty(t, convs)
+ },
+ },
+ {
+ name: "GET lists all conversations",
+ method: http.MethodGet,
+ setupConvs: 3,
+ expectedStatus: http.StatusOK,
+ checkResponse: func(t *testing.T, body string) {
+ var convs []ConversationSummary
+ err := json.Unmarshal([]byte(body), &convs)
+ require.NoError(t, err)
+ assert.Len(t, convs, 3)
+ for _, conv := range convs {
+ assert.NotEmpty(t, conv.ID)
+ assert.NotEmpty(t, conv.Title)
+ assert.NotEmpty(t, conv.Agent)
+ }
+ },
+ },
+ {
+ name: "POST creates new conversation",
+ method: http.MethodPost,
+ setupConvs: 0,
+ expectedStatus: http.StatusCreated,
+ checkResponse: func(t *testing.T, body string) {
+ var resp map[string]string
+ err := json.Unmarshal([]byte(body), &resp)
+ require.NoError(t, err)
+ assert.NotEmpty(t, resp["id"])
+ assert.Equal(t, "New conversation", resp["title"])
+ },
+ },
+ {
+ name: "PUT returns method not allowed",
+ method: http.MethodPut,
+ setupConvs: 0,
+ expectedStatus: http.StatusMethodNotAllowed,
+ checkResponse: func(t *testing.T, body string) {
+ assert.Contains(t, body, "Method not allowed")
+ },
+ },
+ {
+ name: "DELETE returns method not allowed",
+ method: http.MethodDelete,
+ setupConvs: 0,
+ expectedStatus: http.StatusMethodNotAllowed,
+ checkResponse: func(t *testing.T, body string) {
+ assert.Contains(t, body, "Method not allowed")
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ server := setupServerWithMemory(t)
+
+ // Setup: create conversations if needed
+ for i := 0; i < tt.setupConvs; i++ {
+ _, err := server.memory.Create("test-agent")
+ require.NoError(t, err)
+ }
+
+ // Create request
+ req := httptest.NewRequest(tt.method, "/api/conversations", nil)
+ w := httptest.NewRecorder()
+
+ // Execute
+ server.handleConversations(w, req)
+
+ // Verify status code
+ assert.Equal(t, tt.expectedStatus, w.Code)
+
+ // Verify response body
+ if tt.checkResponse != nil {
+ tt.checkResponse(t, w.Body.String())
+ }
+ })
+ }
+}
+
+func TestHandleConversationByID(t *testing.T) {
+ tests := []struct {
+ name string
+ method string
+ setupConv bool // whether to create a conversation first
+ urlPath string // URL path to use (empty = use created conv ID)
+ expectedStatus int
+ checkResponse func(t *testing.T, body string)
+ }{
+ {
+ name: "GET retrieves existing conversation",
+ method: http.MethodGet,
+ setupConv: true,
+ urlPath: "",
+ expectedStatus: http.StatusOK,
+ checkResponse: func(t *testing.T, body string) {
+ var conv Conversation
+ err := json.Unmarshal([]byte(body), &conv)
+ require.NoError(t, err)
+ assert.NotEmpty(t, conv.ID)
+ assert.Equal(t, "New conversation", conv.Title)
+ assert.NotNil(t, conv.Messages)
+ },
+ },
+ {
+ name: "GET returns 404 for non-existent conversation",
+ method: http.MethodGet,
+ setupConv: false,
+ urlPath: "/api/conversations/nonexistent",
+ expectedStatus: http.StatusNotFound,
+ checkResponse: func(t *testing.T, body string) {
+ assert.Contains(t, body, "Conversation not found")
+ },
+ },
+ {
+ name: "GET returns 400 for empty ID",
+ method: http.MethodGet,
+ setupConv: false,
+ urlPath: "/api/conversations/",
+ expectedStatus: http.StatusBadRequest,
+ checkResponse: func(t *testing.T, body string) {
+ assert.Contains(t, body, "conversation ID required")
+ },
+ },
+ {
+ name: "DELETE removes existing conversation",
+ method: http.MethodDelete,
+ setupConv: true,
+ urlPath: "",
+ expectedStatus: http.StatusOK,
+ checkResponse: func(t *testing.T, body string) {
+ var resp map[string]string
+ err := json.Unmarshal([]byte(body), &resp)
+ require.NoError(t, err)
+ assert.Equal(t, "deleted", resp["status"])
+ },
+ },
+ {
+ name: "DELETE returns 404 for non-existent conversation",
+ method: http.MethodDelete,
+ setupConv: false,
+ urlPath: "/api/conversations/nonexistent",
+ expectedStatus: http.StatusNotFound,
+ checkResponse: func(t *testing.T, body string) {
+ assert.Contains(t, body, "Failed to delete conversation")
+ },
+ },
+ {
+ name: "DELETE returns 400 for empty ID",
+ method: http.MethodDelete,
+ setupConv: false,
+ urlPath: "/api/conversations/",
+ expectedStatus: http.StatusBadRequest,
+ checkResponse: func(t *testing.T, body string) {
+ assert.Contains(t, body, "conversation ID required")
+ },
+ },
+ {
+ name: "POST returns method not allowed",
+ method: http.MethodPost,
+ setupConv: true,
+ urlPath: "",
+ expectedStatus: http.StatusMethodNotAllowed,
+ checkResponse: func(t *testing.T, body string) {
+ assert.Contains(t, body, "Method not allowed")
+ },
+ },
+ {
+ name: "PUT returns method not allowed",
+ method: http.MethodPut,
+ setupConv: true,
+ urlPath: "",
+ expectedStatus: http.StatusMethodNotAllowed,
+ checkResponse: func(t *testing.T, body string) {
+ assert.Contains(t, body, "Method not allowed")
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ server := setupServerWithMemory(t)
+
+ // Setup: create conversation if needed
+ var convID string
+ if tt.setupConv {
+ conv, err := server.memory.Create("test-agent")
+ require.NoError(t, err)
+ convID = conv.ID
+ }
+
+ // Build URL path
+ urlPath := tt.urlPath
+ if urlPath == "" && tt.setupConv {
+ urlPath = "/api/conversations/" + convID
+ }
+
+ // Create request
+ req := httptest.NewRequest(tt.method, urlPath, nil)
+ w := httptest.NewRecorder()
+
+ // Execute
+ server.handleConversationByID(w, req)
+
+ // Verify status code
+ assert.Equal(t, tt.expectedStatus, w.Code)
+
+ // Verify response body
+ if tt.checkResponse != nil {
+ tt.checkResponse(t, w.Body.String())
+ }
+ })
+ }
+}
+
+func TestListConversations(t *testing.T) {
+ tests := []struct {
+ name string
+ setupConvs int
+ expectedCount int
+ }{
+ {
+ name: "returns empty array when no conversations",
+ setupConvs: 0,
+ expectedCount: 0,
+ },
+ {
+ name: "lists single conversation",
+ setupConvs: 1,
+ expectedCount: 1,
+ },
+ {
+ name: "lists multiple conversations",
+ setupConvs: 5,
+ expectedCount: 5,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ server := setupServerWithMemory(t)
+
+ // Setup: create conversations
+ for i := 0; i < tt.setupConvs; i++ {
+ _, err := server.memory.Create("test-agent")
+ require.NoError(t, err)
+ }
+
+ // Create request
+ req := httptest.NewRequest(http.MethodGet, "/api/conversations", nil)
+ w := httptest.NewRecorder()
+
+ // Execute
+ server.listConversations(w, req)
+
+ // Verify response
+ assert.Equal(t, http.StatusOK, w.Code)
+ assert.Contains(t, w.Header().Get("Content-Type"), "application/json")
+
+ var convs []ConversationSummary
+ err := json.Unmarshal(w.Body.Bytes(), &convs)
+ require.NoError(t, err)
+ assert.Len(t, convs, tt.expectedCount)
+ })
+ }
+}
+
+func TestCreateConversation(t *testing.T) {
+ tests := []struct {
+ name string
+ agentType AgentType
+ }{
+ {
+ name: "creates conversation with claude agent",
+ agentType: AgentClaude,
+ },
+ {
+ name: "creates conversation with ollama agent",
+ agentType: AgentOllama,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ memory := setupMemoryStore(t)
+ server := &Server{
+ agent: &Agent{Type: tt.agentType},
+ memory: memory,
+ }
+
+ // Create request
+ req := httptest.NewRequest(http.MethodPost, "/api/conversations", nil)
+ w := httptest.NewRecorder()
+
+ // Execute
+ server.createConversation(w, req)
+
+ // Verify response
+ assert.Equal(t, http.StatusCreated, w.Code)
+ assert.Contains(t, w.Header().Get("Content-Type"), "application/json")
+
+ var resp map[string]string
+ err := json.Unmarshal(w.Body.Bytes(), &resp)
+ require.NoError(t, err)
+ assert.NotEmpty(t, resp["id"])
+ assert.Equal(t, "New conversation", resp["title"])
+
+ // Verify conversation was actually created
+ conv, err := memory.Get(resp["id"])
+ require.NoError(t, err)
+ assert.Equal(t, resp["id"], conv.ID)
+ assert.Equal(t, string(tt.agentType), conv.Agent)
+ })
+ }
+}
+
+func TestGetConversation(t *testing.T) {
+ tests := []struct {
+ name string
+ setupConv bool
+ convID string
+ expectedStatus int
+ }{
+ {
+ name: "retrieves existing conversation",
+ setupConv: true,
+ convID: "",
+ expectedStatus: http.StatusOK,
+ },
+ {
+ name: "returns 404 for non-existent conversation",
+ setupConv: false,
+ convID: "nonexistent",
+ expectedStatus: http.StatusNotFound,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ server := setupServerWithMemory(t)
+
+ // Setup: create conversation if needed
+ convID := tt.convID
+ if tt.setupConv {
+ conv, err := server.memory.Create("test-agent")
+ require.NoError(t, err)
+ convID = conv.ID
+ }
+
+ // Create request
+ w := httptest.NewRecorder()
+
+ // Execute
+ server.getConversation(w, convID)
+
+ // Verify status
+ assert.Equal(t, tt.expectedStatus, w.Code)
+
+ // Verify response body for successful retrieval
+ if tt.expectedStatus == http.StatusOK {
+ assert.Contains(t, w.Header().Get("Content-Type"), "application/json")
+ var conv Conversation
+ err := json.Unmarshal(w.Body.Bytes(), &conv)
+ require.NoError(t, err)
+ assert.Equal(t, convID, conv.ID)
+ assert.NotNil(t, conv.Messages)
+ }
+ })
+ }
+}
+
+func TestDeleteConversation(t *testing.T) {
+ tests := []struct {
+ name string
+ setupConv bool
+ convID string
+ expectedStatus int
+ }{
+ {
+ name: "deletes existing conversation",
+ setupConv: true,
+ convID: "",
+ expectedStatus: http.StatusOK,
+ },
+ {
+ name: "returns 404 for non-existent conversation",
+ setupConv: false,
+ convID: "nonexistent",
+ expectedStatus: http.StatusNotFound,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ server := setupServerWithMemory(t)
+
+ // Setup: create conversation if needed
+ convID := tt.convID
+ if tt.setupConv {
+ conv, err := server.memory.Create("test-agent")
+ require.NoError(t, err)
+ convID = conv.ID
+ }
+
+ // Create request
+ w := httptest.NewRecorder()
+
+ // Execute
+ server.deleteConversation(w, convID)
+
+ // Verify status
+ assert.Equal(t, tt.expectedStatus, w.Code)
+
+ // Verify response body
+ if tt.expectedStatus == http.StatusOK {
+ assert.Contains(t, w.Header().Get("Content-Type"), "application/json")
+ var resp map[string]string
+ err := json.Unmarshal(w.Body.Bytes(), &resp)
+ require.NoError(t, err)
+ assert.Equal(t, "deleted", resp["status"])
+
+ // Verify conversation is actually deleted
+ _, err = server.memory.Get(convID)
+ assert.Error(t, err)
+ }
+ })
+ }
+}
+
+func TestConversationEndToEnd(t *testing.T) {
+ server := setupServerWithMemory(t)
+
+ // List conversations (should be empty)
+ req := httptest.NewRequest(http.MethodGet, "/api/conversations", nil)
+ w := httptest.NewRecorder()
+ server.handleConversations(w, req)
+ assert.Equal(t, http.StatusOK, w.Code)
+ var convs []ConversationSummary
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &convs))
+ assert.Empty(t, convs)
+
+ // Create a conversation
+ req = httptest.NewRequest(http.MethodPost, "/api/conversations", nil)
+ w = httptest.NewRecorder()
+ server.handleConversations(w, req)
+ assert.Equal(t, http.StatusCreated, w.Code)
+ var createResp map[string]string
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &createResp))
+ convID := createResp["id"]
+ assert.NotEmpty(t, convID)
+
+ // Get the conversation by ID
+ req = httptest.NewRequest(http.MethodGet, "/api/conversations/"+convID, nil)
+ w = httptest.NewRecorder()
+ server.handleConversationByID(w, req)
+ assert.Equal(t, http.StatusOK, w.Code)
+ var conv Conversation
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &conv))
+ assert.Equal(t, convID, conv.ID)
+
+ // List conversations (should have one)
+ req = httptest.NewRequest(http.MethodGet, "/api/conversations", nil)
+ w = httptest.NewRecorder()
+ server.handleConversations(w, req)
+ assert.Equal(t, http.StatusOK, w.Code)
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &convs))
+ assert.Len(t, convs, 1)
+ assert.Equal(t, convID, convs[0].ID)
+
+ // Delete the conversation
+ req = httptest.NewRequest(http.MethodDelete, "/api/conversations/"+convID, nil)
+ w = httptest.NewRecorder()
+ server.handleConversationByID(w, req)
+ assert.Equal(t, http.StatusOK, w.Code)
+
+ // Verify it's deleted
+ req = httptest.NewRequest(http.MethodGet, "/api/conversations/"+convID, nil)
+ w = httptest.NewRecorder()
+ server.handleConversationByID(w, req)
+ assert.Equal(t, http.StatusNotFound, w.Code)
+}
+
+func TestMemoryStoreFailure(t *testing.T) {
+ t.Run("listConversations handles deleted directory", func(t *testing.T) {
+ tempDir := t.TempDir()
+ memory, err := NewMemoryStore(tempDir)
+ require.NoError(t, err)
+ server := &Server{agent: &Agent{Type: AgentClaude}, memory: memory}
+
+ _ = os.RemoveAll(tempDir)
+ req := httptest.NewRequest(http.MethodGet, "/api/conversations", nil)
+ w := httptest.NewRecorder()
+ server.listConversations(w, req)
+ assert.Equal(t, http.StatusOK, w.Code)
+ })
+
+ t.Run("createConversation handles permission error", func(t *testing.T) {
+ tempDir := t.TempDir()
+ memory, err := NewMemoryStore(tempDir)
+ require.NoError(t, err)
+ require.NoError(t, os.Chmod(tempDir, 0444))
+ t.Cleanup(func() { _ = os.Chmod(tempDir, 0755) })
+
+ server := &Server{agent: &Agent{Type: AgentClaude}, memory: memory}
+ req := httptest.NewRequest(http.MethodPost, "/api/conversations", nil)
+ w := httptest.NewRecorder()
+ server.createConversation(w, req)
+
+ assert.Equal(t, http.StatusInternalServerError, w.Code)
+ assert.Contains(t, w.Body.String(), "Failed to create conversation")
+ })
+
+ t.Run("getConversation handles corrupted file", func(t *testing.T) {
+ tempDir := t.TempDir()
+ memory, err := NewMemoryStore(tempDir)
+ require.NoError(t, err)
+
+ corruptedPath := filepath.Join(tempDir, "corrupt.json")
+ require.NoError(t, os.WriteFile(corruptedPath, []byte("not valid json"), 0600))
+
+ server := &Server{agent: &Agent{Type: AgentClaude}, memory: memory}
+ w := httptest.NewRecorder()
+ server.getConversation(w, "corrupt")
+ assert.Equal(t, http.StatusNotFound, w.Code)
+ })
+}
diff --git a/internal/chat/prompt_test.go b/internal/chat/prompt_test.go
new file mode 100644
index 0000000..06a1c62
--- /dev/null
+++ b/internal/chat/prompt_test.go
@@ -0,0 +1,102 @@
+package chat
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestBuildSystemPrompt(t *testing.T) {
+ tests := []struct {
+ name string
+ grantID string
+ agentType AgentType
+ want []string // Expected substrings in the output
+ }{
+ {
+ name: "claude agent with grant ID",
+ grantID: "grant-123",
+ agentType: AgentClaude,
+ want: []string{
+ "You are a helpful email and calendar assistant",
+ "Grant ID: grant-123",
+ "## Tool Usage",
+ "TOOL_CALL:",
+ "## Conversation Context",
+ "## Response Format",
+ "Use markdown formatting",
+ },
+ },
+ {
+ name: "codex agent with grant ID",
+ grantID: "grant-456",
+ agentType: AgentCodex,
+ want: []string{
+ "Grant ID: grant-456",
+ "TOOL_CALL:",
+ },
+ },
+ {
+ name: "ollama agent with grant ID",
+ grantID: "grant-789",
+ agentType: AgentOllama,
+ want: []string{
+ "Grant ID: grant-789",
+ },
+ },
+ {
+ name: "empty grant ID",
+ grantID: "",
+ agentType: AgentClaude,
+ want: []string{
+ "Grant ID: ",
+ "You are a helpful email and calendar assistant",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := BuildSystemPrompt(tt.grantID, tt.agentType)
+
+ // Verify the output is non-empty
+ if got == "" {
+ t.Error("BuildSystemPrompt returned empty string")
+ }
+
+ // Check that all expected substrings are present
+ for _, want := range tt.want {
+ if !strings.Contains(got, want) {
+ t.Errorf("BuildSystemPrompt output missing expected substring:\nwant: %q\ngot: %s", want, got)
+ }
+ }
+ })
+ }
+}
+
+func TestBuildSystemPrompt_Structure(t *testing.T) {
+ grantID := "test-grant"
+ agentType := AgentClaude
+ prompt := BuildSystemPrompt(grantID, agentType)
+
+ // Verify key sections are present in order
+ sections := []string{
+ "You are a helpful email and calendar assistant",
+ "Grant ID:",
+ "## Tool Usage",
+ "## Conversation Context",
+ "## Response Format",
+ }
+
+ lastIndex := -1
+ for _, section := range sections {
+ index := strings.Index(prompt, section)
+ if index == -1 {
+ t.Errorf("Missing section in prompt: %q", section)
+ continue
+ }
+ if index <= lastIndex {
+ t.Errorf("Section %q appears before previous section (out of order)", section)
+ }
+ lastIndex = index
+ }
+}
diff --git a/internal/chat/server.go b/internal/chat/server.go
index 6f022a8..7acb800 100644
--- a/internal/chat/server.go
+++ b/internal/chat/server.go
@@ -19,17 +19,18 @@ var templateFiles embed.FS
// Server is the chat web UI HTTP server.
type Server struct {
- addr string
- agent *Agent
- agents []Agent
- agentMu sync.RWMutex // protects agent switching
- nylas ports.NylasClient
- grantID string
- memory *MemoryStore
- executor *ToolExecutor
- context *ContextBuilder
- session *ActiveSession
- tmpl *template.Template
+ addr string
+ agent *Agent
+ agents []Agent
+ agentMu sync.RWMutex // protects agent switching
+ nylas ports.NylasClient
+ grantID string
+ memory *MemoryStore
+ executor *ToolExecutor
+ context *ContextBuilder
+ session *ActiveSession
+ approvals *ApprovalStore
+ tmpl *template.Template
}
// ActiveAgent returns the current agent (thread-safe).
@@ -60,16 +61,17 @@ func NewServer(addr string, agent *Agent, agents []Agent, nylas ports.NylasClien
tmpl, _ := template.New("").ParseFS(templateFiles, "templates/*.gohtml")
return &Server{
- addr: addr,
- agent: agent,
- agents: agents,
- nylas: nylas,
- grantID: grantID,
- memory: memory,
- executor: executor,
- context: ctx,
- session: NewActiveSession(),
- tmpl: tmpl,
+ addr: addr,
+ agent: agent,
+ agents: agents,
+ nylas: nylas,
+ grantID: grantID,
+ memory: memory,
+ executor: executor,
+ context: ctx,
+ session: NewActiveSession(),
+ approvals: NewApprovalStore(),
+ tmpl: tmpl,
}
}
@@ -79,6 +81,9 @@ func (s *Server) Start() error {
// API routes
mux.HandleFunc("/api/chat", s.handleChat)
+ mux.HandleFunc("/api/chat/approve", s.handleApprove)
+ mux.HandleFunc("/api/chat/reject", s.handleReject)
+ mux.HandleFunc("/api/command", s.handleCommand)
mux.HandleFunc("/api/conversations", s.handleConversations)
mux.HandleFunc("/api/conversations/", s.handleConversationByID)
mux.HandleFunc("/api/config", s.handleConfig)
@@ -103,7 +108,7 @@ func (s *Server) Start() error {
Addr: s.addr,
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
- WriteTimeout: 120 * time.Second, // long for SSE streaming
+ WriteTimeout: 360 * time.Second, // long for SSE streaming + approval gating
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 20,
}
diff --git a/internal/chat/session_test.go b/internal/chat/session_test.go
new file mode 100644
index 0000000..ec5bc62
--- /dev/null
+++ b/internal/chat/session_test.go
@@ -0,0 +1,143 @@
+package chat
+
+import (
+ "sync"
+ "testing"
+)
+
+func TestNewActiveSession(t *testing.T) {
+ session := NewActiveSession()
+
+ if session == nil {
+ t.Fatal("NewActiveSession returned nil")
+ }
+
+ if session.Get() != "" {
+ t.Errorf("NewActiveSession should return empty conversation ID, got: %q", session.Get())
+ }
+}
+
+func TestActiveSession_GetSet(t *testing.T) {
+ tests := []struct {
+ name string
+ conversationID string
+ }{
+ {
+ name: "simple ID",
+ conversationID: "conv-123",
+ },
+ {
+ name: "empty ID",
+ conversationID: "",
+ },
+ {
+ name: "long ID",
+ conversationID: "conversation-with-very-long-identifier-12345678901234567890",
+ },
+ {
+ name: "ID with special characters",
+ conversationID: "conv-123_test@2024",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ session := NewActiveSession()
+
+ // Set the conversation ID
+ session.Set(tt.conversationID)
+
+ // Get the conversation ID
+ got := session.Get()
+
+ if got != tt.conversationID {
+ t.Errorf("Get() = %q, want %q", got, tt.conversationID)
+ }
+ })
+ }
+}
+
+func TestActiveSession_Overwrite(t *testing.T) {
+ session := NewActiveSession()
+
+ // Set first value
+ session.Set("first-conv")
+ if got := session.Get(); got != "first-conv" {
+ t.Errorf("After first Set(), Get() = %q, want %q", got, "first-conv")
+ }
+
+ // Overwrite with second value
+ session.Set("second-conv")
+ if got := session.Get(); got != "second-conv" {
+ t.Errorf("After second Set(), Get() = %q, want %q", got, "second-conv")
+ }
+
+ // Overwrite with empty value
+ session.Set("")
+ if got := session.Get(); got != "" {
+ t.Errorf("After Set(\"\"), Get() = %q, want \"\"", got)
+ }
+}
+
+func TestActiveSession_Concurrent(t *testing.T) {
+ session := NewActiveSession()
+ const goroutines = 100
+ const iterations = 100
+
+ var wg sync.WaitGroup
+ wg.Add(goroutines * 2) // readers and writers
+
+ // Concurrent writers
+ for i := 0; i < goroutines; i++ {
+ go func(id int) {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ session.Set(string(rune(id)))
+ }
+ }(i)
+ }
+
+ // Concurrent readers
+ for i := 0; i < goroutines; i++ {
+ go func() {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ _ = session.Get()
+ }
+ }()
+ }
+
+ wg.Wait()
+
+ // If we got here without race detector warnings, the test passed
+ // The final value doesn't matter - we just verify thread safety
+ _ = session.Get()
+}
+
+func TestActiveSession_ConcurrentReadWrite(t *testing.T) {
+ session := NewActiveSession()
+ done := make(chan bool)
+
+ // Writer goroutine
+ go func() {
+ for i := 0; i < 1000; i++ {
+ session.Set("write")
+ }
+ done <- true
+ }()
+
+ // Reader goroutine
+ go func() {
+ for i := 0; i < 1000; i++ {
+ _ = session.Get()
+ }
+ done <- true
+ }()
+
+ // Wait for both to complete
+ <-done
+ <-done
+
+ // Verify final state is accessible
+ _ = session.Get()
+}
diff --git a/internal/chat/static/css/chat.css b/internal/chat/static/css/chat.css
index 2a2ae69..311f921 100644
--- a/internal/chat/static/css/chat.css
+++ b/internal/chat/static/css/chat.css
@@ -289,152 +289,6 @@ body {
background: var(--accent-subtle);
}
-/* Messages */
-.message {
- display: flex;
- margin-bottom: 20px;
- max-width: 720px;
- margin-left: auto;
- margin-right: auto;
- width: 100%;
-}
-
-.message.user { justify-content: flex-end; }
-
-.message-content {
- padding: 12px 16px;
- border-radius: 18px;
- font-size: 14px;
- line-height: 1.6;
- word-break: break-word;
- max-width: 85%;
-}
-
-.message.user .message-content {
- background: var(--bg-message-user);
- border-radius: 18px 18px 4px 18px;
-}
-
-.message.assistant .message-content {
- background: var(--bg-message-assistant);
- padding-left: 0;
- padding-right: 0;
- max-width: 100%;
-}
-
-.message-content p { margin-bottom: 10px; }
-.message-content p:last-child { margin-bottom: 0; }
-
-.message-content code {
- background: rgba(139,92,246,0.1);
- padding: 2px 6px;
- border-radius: 4px;
- font-size: 13px;
- font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
-}
-
-.message-content pre {
- background: rgba(0,0,0,0.4);
- padding: 14px 16px;
- border-radius: var(--radius-sm);
- overflow-x: auto;
- margin: 10px 0;
- font-size: 13px;
- line-height: 1.5;
-}
-
-@media (prefers-color-scheme: light) {
- .message-content pre { background: #f3f4f6; }
-}
-
-.message-content pre code {
- background: none;
- padding: 0;
- font-size: inherit;
-}
-
-.message-content ul, .message-content ol {
- padding-left: 20px;
- margin: 6px 0;
-}
-
-.message-content li { margin-bottom: 4px; }
-
-.message-content a {
- color: var(--accent);
- text-decoration: none;
-}
-
-.message-content a:hover { text-decoration: underline; }
-
-/* Tool Call Indicators */
-.tool-indicator {
- font-size: 12px;
- color: var(--text-secondary);
- padding: 8px 14px;
- background: var(--bg-tool);
- border: 1px solid var(--border-color);
- border-radius: var(--radius-sm);
- margin-bottom: 10px;
- max-width: 720px;
- margin-left: auto;
- margin-right: auto;
- cursor: pointer;
- transition: background var(--transition);
-}
-
-.tool-indicator:hover { background: var(--bg-hover); }
-.tool-indicator .tool-name { font-weight: 600; color: var(--accent); }
-
-.tool-details {
- display: none;
- font-size: 12px;
- padding: 10px;
- background: rgba(0,0,0,0.15);
- border-radius: 6px;
- margin-top: 6px;
- white-space: pre-wrap;
- max-height: 200px;
- overflow-y: auto;
- font-family: 'SF Mono', 'Fira Code', monospace;
- line-height: 1.5;
-}
-
-@media (prefers-color-scheme: light) {
- .tool-details { background: rgba(0,0,0,0.04); }
-}
-
-.tool-indicator.expanded .tool-details { display: block; }
-
-/* Thinking Indicator */
-.thinking {
- display: flex;
- align-items: center;
- gap: 10px;
- color: var(--text-muted);
- font-size: 13px;
- padding: 12px 0;
- max-width: 720px;
- margin: 0 auto;
-}
-
-.thinking-dots span {
- display: inline-block;
- width: 5px;
- height: 5px;
- background: var(--accent);
- border-radius: 50%;
- animation: pulse 1.4s infinite ease-in-out;
-}
-
-.thinking-dots span:nth-child(2) { animation-delay: 0.2s; }
-.thinking-dots span:nth-child(3) { animation-delay: 0.4s; }
-
-@keyframes pulse {
- 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
- 40% { opacity: 1; transform: scale(1); }
-}
-
/* Input Area */
.input-area {
padding: 16px 24px 24px;
diff --git a/internal/chat/static/css/components.css b/internal/chat/static/css/components.css
new file mode 100644
index 0000000..ae7fe66
--- /dev/null
+++ b/internal/chat/static/css/components.css
@@ -0,0 +1,280 @@
+/* Message and Component Styles */
+
+/* Messages */
+.message {
+ display: flex;
+ margin-bottom: 20px;
+ max-width: 720px;
+ margin-left: auto;
+ margin-right: auto;
+ width: 100%;
+}
+
+.message.user { justify-content: flex-end; }
+
+.message-content {
+ padding: 12px 16px;
+ border-radius: 18px;
+ font-size: 14px;
+ line-height: 1.6;
+ word-break: break-word;
+ max-width: 85%;
+}
+
+.message.user .message-content {
+ background: var(--bg-message-user);
+ border-radius: 18px 18px 4px 18px;
+}
+
+.message.assistant .message-content {
+ background: var(--bg-message-assistant);
+ padding-left: 0;
+ padding-right: 0;
+ max-width: 100%;
+}
+
+.message-content p { margin-bottom: 10px; }
+.message-content p:last-child { margin-bottom: 0; }
+
+.message-content code {
+ background: rgba(139,92,246,0.1);
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 13px;
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
+}
+
+.message-content pre {
+ background: rgba(0,0,0,0.4);
+ padding: 14px 16px;
+ border-radius: var(--radius-sm);
+ overflow-x: auto;
+ margin: 10px 0;
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+@media (prefers-color-scheme: light) {
+ .message-content pre { background: #f3f4f6; }
+}
+
+.message-content pre code {
+ background: none;
+ padding: 0;
+ font-size: inherit;
+}
+
+.message-content ul, .message-content ol {
+ padding-left: 20px;
+ margin: 6px 0;
+}
+
+.message-content li { margin-bottom: 4px; }
+
+.message-content a {
+ color: var(--accent);
+ text-decoration: none;
+}
+
+.message-content a:hover { text-decoration: underline; }
+
+/* Tool Call Indicators */
+.tool-indicator {
+ font-size: 12px;
+ color: var(--text-secondary);
+ padding: 8px 14px;
+ background: var(--bg-tool);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-sm);
+ margin-bottom: 10px;
+ max-width: 720px;
+ margin-left: auto;
+ margin-right: auto;
+ cursor: pointer;
+ transition: background var(--transition);
+}
+
+.tool-indicator:hover { background: var(--bg-hover); }
+.tool-indicator .tool-name { font-weight: 600; color: var(--accent); }
+
+.tool-details {
+ display: none;
+ font-size: 12px;
+ padding: 10px;
+ background: rgba(0,0,0,0.15);
+ border-radius: 6px;
+ margin-top: 6px;
+ white-space: pre-wrap;
+ max-height: 200px;
+ overflow-y: auto;
+ font-family: 'SF Mono', 'Fira Code', monospace;
+ line-height: 1.5;
+}
+
+@media (prefers-color-scheme: light) {
+ .tool-details { background: rgba(0,0,0,0.04); }
+}
+
+.tool-indicator.expanded .tool-details { display: block; }
+
+/* Thinking Indicator */
+.thinking {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ color: var(--text-muted);
+ font-size: 13px;
+ padding: 12px 0;
+ max-width: 720px;
+ margin: 0 auto;
+}
+
+.thinking-dots span {
+ display: inline-block;
+ width: 5px;
+ height: 5px;
+ background: var(--accent);
+ border-radius: 50%;
+ animation: pulse 1.4s infinite ease-in-out;
+}
+
+.thinking-dots span:nth-child(2) { animation-delay: 0.2s; }
+.thinking-dots span:nth-child(3) { animation-delay: 0.4s; }
+
+@keyframes pulse {
+ 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
+ 40% { opacity: 1; transform: scale(1); }
+}
+
+/* System Messages */
+.message.system {
+ justify-content: center;
+}
+
+.message.system .message-content {
+ background: var(--bg-tool);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-sm);
+ color: var(--text-secondary);
+ font-size: 13px;
+ max-width: 90%;
+ padding: 10px 16px;
+}
+
+.message.system .message-content code {
+ background: rgba(139,92,246,0.15);
+ font-size: 12px;
+}
+
+/* Approval Cards */
+.approval-card {
+ max-width: 720px;
+ margin: 0 auto 16px;
+ background: var(--bg-tool);
+ border: 1px solid var(--accent);
+ border-radius: var(--radius);
+ padding: 16px;
+ animation: slideIn 0.2s ease;
+}
+
+.approval-card .approval-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 12px;
+ font-weight: 600;
+ font-size: 14px;
+ color: var(--accent);
+}
+
+.approval-card .approval-preview {
+ background: rgba(0,0,0,0.15);
+ border-radius: var(--radius-sm);
+ padding: 12px;
+ margin-bottom: 14px;
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+@media (prefers-color-scheme: light) {
+ .approval-card .approval-preview { background: rgba(0,0,0,0.04); }
+}
+
+.approval-card .approval-preview dt {
+ font-weight: 600;
+ color: var(--text-secondary);
+ margin-top: 6px;
+}
+
+.approval-card .approval-preview dt:first-child { margin-top: 0; }
+
+.approval-card .approval-preview dd {
+ margin-left: 0;
+ color: var(--text-primary);
+ word-break: break-word;
+}
+
+.approval-card .approval-actions {
+ display: flex;
+ gap: 8px;
+}
+
+.btn-approve, .btn-reject {
+ padding: 8px 20px;
+ border: none;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ font-size: 13px;
+ font-weight: 500;
+ font-family: inherit;
+ transition: all var(--transition);
+}
+
+.btn-approve {
+ background: var(--success);
+ color: #000;
+}
+
+.btn-approve:hover { filter: brightness(1.1); }
+
+.btn-reject {
+ background: transparent;
+ border: 1px solid var(--danger);
+ color: var(--danger);
+}
+
+.btn-reject:hover { background: rgba(248,113,113,0.1); }
+
+.approval-card.resolved {
+ opacity: 0.6;
+ border-color: var(--border-color);
+}
+
+.approval-card.resolved .approval-actions { display: none; }
+
+.approval-card.resolved .approval-status {
+ font-size: 12px;
+ color: var(--text-muted);
+ font-style: italic;
+}
+
+@keyframes slideIn {
+ from { opacity: 0; transform: translateY(-8px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+/* Streaming Cursor */
+.message.streaming .message-content::after {
+ content: '';
+ display: inline-block;
+ width: 7px;
+ height: 16px;
+ background: var(--accent);
+ margin-left: 2px;
+ vertical-align: text-bottom;
+ animation: blink 1s step-end infinite;
+}
+
+@keyframes blink {
+ 50% { opacity: 0; }
+}
diff --git a/internal/chat/static/js/api.js b/internal/chat/static/js/api.js
index 8246383..5cce14e 100644
--- a/internal/chat/static/js/api.js
+++ b/internal/chat/static/js/api.js
@@ -73,4 +73,36 @@ const ChatAPI = {
if (!resp.ok) throw new Error(await resp.text());
return resp.json();
},
+
+ async executeCommand(name, args, conversationId) {
+ const body = { name, args };
+ if (conversationId) body.conversation_id = conversationId;
+ const resp = await fetch('/api/command', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ if (!resp.ok) throw new Error(await resp.text());
+ return resp.json();
+ },
+
+ async approveAction(approvalId) {
+ const resp = await fetch('/api/chat/approve', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ approval_id: approvalId }),
+ });
+ if (!resp.ok) throw new Error(await resp.text());
+ return resp.json();
+ },
+
+ async rejectAction(approvalId, reason) {
+ const resp = await fetch('/api/chat/reject', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ approval_id: approvalId, reason: reason || '' }),
+ });
+ if (!resp.ok) throw new Error(await resp.text());
+ return resp.json();
+ },
};
diff --git a/internal/chat/static/js/chat.js b/internal/chat/static/js/chat.js
index 573c31c..4d795c5 100644
--- a/internal/chat/static/js/chat.js
+++ b/internal/chat/static/js/chat.js
@@ -2,6 +2,8 @@
const Chat = {
conversationId: null,
sending: false,
+ streamingEl: null,
+ streamingContent: '',
init() {
const form = document.getElementById('chat-form');
@@ -18,9 +20,13 @@ const Chat = {
input.style.height = Math.min(input.scrollHeight, 120) + 'px';
});
- // Enter to send, Shift+Enter for newline
+ // Enter to send, Shift+Enter for newline; Tab for command completion
input.addEventListener('keydown', (e) => {
- if (e.key === 'Enter' && !e.shiftKey) {
+ if (e.key === 'Tab' && input.value.startsWith('/')) {
+ e.preventDefault();
+ const completed = Commands.complete(input.value);
+ if (completed) input.value = completed;
+ } else if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.send();
}
@@ -40,6 +46,21 @@ const Chat = {
const message = input.value.trim();
if (!message || this.sending) return;
+ // Check for slash commands
+ const parsed = Commands.parse(message);
+ if (parsed.isCommand) {
+ input.value = '';
+ input.style.height = 'auto';
+ this.hideWelcome();
+
+ if (parsed.type === 'client') {
+ await Commands.executeClient(parsed.name, parsed.args);
+ } else {
+ await Commands.executeServer(parsed.name, parsed.args, this.conversationId);
+ }
+ return;
+ }
+
this.sending = true;
this.hideWelcome();
this.appendMessage('user', message);
@@ -55,18 +76,37 @@ const Chat = {
case 'thinking':
// Already showing indicator
break;
+ case 'token':
+ thinkingEl.remove();
+ this.handleStreamToken(data.text);
+ break;
+ case 'stream_end':
+ this.finalizeStream();
+ break;
+ case 'stream_discard':
+ this.discardStream();
+ break;
case 'tool_call':
this.appendToolCall(data);
break;
case 'tool_result':
this.appendToolResult(data);
break;
+ case 'approval_required':
+ this.showApprovalCard(data);
+ break;
+ case 'approval_resolved':
+ this.resolveApprovalCard(data);
+ break;
case 'message':
thinkingEl.remove();
- this.appendMessage('assistant', data.content);
+ if (!this.streamingEl) {
+ this.appendMessage('assistant', data.content);
+ }
break;
case 'error':
thinkingEl.remove();
+ this.discardStream();
this.appendMessage('assistant', 'Error: ' + data.error);
break;
case 'done':
@@ -83,6 +123,7 @@ const Chat = {
});
} catch (err) {
thinkingEl.remove();
+ this.discardStream();
this.appendMessage('assistant', 'Connection error: ' + err.message);
}
@@ -91,10 +132,100 @@ const Chat = {
input.focus();
},
+ // Streaming support
+ handleStreamToken(text) {
+ if (!this.streamingEl) {
+ this.streamingEl = this.createStreamingMessage();
+ this.streamingContent = '';
+ }
+ this.streamingContent += text;
+ this.renderStream();
+ },
+
+ createStreamingMessage() {
+ const messages = document.getElementById('messages');
+ const div = document.createElement('div');
+ div.className = 'message assistant streaming';
+ const bubble = document.createElement('div');
+ bubble.className = 'message-content';
+ div.appendChild(bubble);
+ messages.appendChild(div);
+ return div;
+ },
+
+ renderStream() {
+ if (!this.streamingEl) return;
+ const bubble = this.streamingEl.querySelector('.message-content');
+ bubble.innerHTML = Markdown.render(this.streamingContent);
+ this.scrollToBottom();
+ },
+
+ finalizeStream() {
+ if (this.streamingEl) {
+ this.streamingEl.classList.remove('streaming');
+ this.renderStream();
+ this.streamingEl = null;
+ this.streamingContent = '';
+ }
+ },
+
+ discardStream() {
+ if (this.streamingEl) {
+ this.streamingEl.remove();
+ this.streamingEl = null;
+ this.streamingContent = '';
+ }
+ },
+
+ // Approval gating
+ showApprovalCard(data) {
+ const messages = document.getElementById('messages');
+ const card = document.createElement('div');
+ card.className = 'approval-card';
+ card.id = 'approval-' + data.approval_id;
+
+ let previewHtml = '';
+ for (const [key, val] of Object.entries(data.preview || {})) {
+ previewHtml += '- ' + this.escapeHtml(key) + '
' +
+ '- ' + this.escapeHtml(String(val)) + '
';
+ }
+ previewHtml += '
';
+
+ card.innerHTML =
+ '' +
+ previewHtml +
+ '' +
+ '' +
+ '' +
+ '
' +
+ '';
+
+ messages.appendChild(card);
+ this.scrollToBottom();
+ },
+
+ resolveApprovalCard(data) {
+ const card = document.getElementById('approval-' + data.approval_id);
+ if (!card) return;
+ card.classList.add('resolved');
+ const status = card.querySelector('.approval-status');
+ status.textContent = data.approved ? 'Approved' : 'Rejected' + (data.reason ? ': ' + data.reason : '');
+ },
+
+ async approve(approvalId) {
+ try { await ChatAPI.approveAction(approvalId); } catch { /* handled via SSE */ }
+ },
+
+ async reject(approvalId) {
+ try { await ChatAPI.rejectAction(approvalId, 'rejected by user'); } catch { /* handled via SSE */ }
+ },
+
loadConversation(conv) {
this.conversationId = conv.id;
const messages = document.getElementById('messages');
+ const welcome = document.getElementById('welcome');
messages.innerHTML = '';
+ if (welcome) messages.appendChild(welcome);
this.hideWelcome();
document.getElementById('chat-title').textContent = conv.title || 'New conversation';
@@ -121,7 +252,9 @@ const Chat = {
startNew(id) {
this.conversationId = id;
const messages = document.getElementById('messages');
+ const welcome = document.getElementById('welcome');
messages.innerHTML = '';
+ if (welcome) messages.appendChild(welcome);
document.getElementById('chat-title').textContent = 'New conversation';
this.showWelcome();
},
@@ -145,6 +278,20 @@ const Chat = {
this.scrollToBottom();
},
+ appendSystemMessage(content) {
+ const messages = document.getElementById('messages');
+ const div = document.createElement('div');
+ div.className = 'message system';
+
+ const bubble = document.createElement('div');
+ bubble.className = 'message-content';
+ bubble.innerHTML = Markdown.render(content);
+
+ div.appendChild(bubble);
+ messages.appendChild(div);
+ this.scrollToBottom();
+ },
+
appendToolCall(data) {
const messages = document.getElementById('messages');
const div = document.createElement('div');
diff --git a/internal/chat/static/js/commands.js b/internal/chat/static/js/commands.js
new file mode 100644
index 0000000..fa844f0
--- /dev/null
+++ b/internal/chat/static/js/commands.js
@@ -0,0 +1,124 @@
+// commands.js — Slash command handling
+const Commands = {
+ // Registry of all commands
+ registry: [
+ { name: 'help', type: 'client', description: 'Show available commands' },
+ { name: 'new', type: 'client', description: 'Start a new conversation' },
+ { name: 'reset', type: 'client', description: 'Start a new conversation' },
+ { name: 'clear', type: 'client', description: 'Clear current messages' },
+ { name: 'model', type: 'client', description: 'Switch AI agent (e.g. /model claude)', args: '' },
+ { name: 'agent', type: 'client', description: 'Switch AI agent (e.g. /agent ollama)', args: '' },
+ { name: 'status', type: 'server', description: 'Show current session status' },
+ { name: 'email', type: 'server', description: 'Quick email lookup (e.g. /email from:sarah)', args: '[query]' },
+ { name: 'calendar', type: 'server', description: 'Show upcoming events (e.g. /calendar 7)', args: '[days]' },
+ { name: 'contacts', type: 'server', description: 'Search contacts (e.g. /contacts john)', args: '[query]' },
+ ],
+
+ // Parse input to check if it's a command
+ parse(input) {
+ const trimmed = input.trim();
+ if (!trimmed.startsWith('/')) {
+ return { isCommand: false };
+ }
+
+ const parts = trimmed.slice(1).split(/\s+/);
+ const name = parts[0].toLowerCase();
+ const args = parts.slice(1).join(' ');
+
+ const cmd = this.registry.find(c => c.name === name);
+ if (!cmd) {
+ return { isCommand: false };
+ }
+
+ return { isCommand: true, name, args, type: cmd.type };
+ },
+
+ // Execute a client-side command. Returns true if handled.
+ async executeClient(name, args) {
+ switch (name) {
+ case 'help':
+ this.showHelp();
+ return true;
+ case 'new':
+ case 'reset':
+ await Sidebar.newChat();
+ return true;
+ case 'clear':
+ this.clearMessages();
+ return true;
+ case 'model':
+ case 'agent':
+ this.switchAgent(args);
+ return true;
+ default:
+ return false;
+ }
+ },
+
+ // Execute a server-side command via API
+ async executeServer(name, args, conversationId) {
+ try {
+ const result = await ChatAPI.executeCommand(name, args, conversationId);
+ Chat.appendSystemMessage(result.content || result.error || 'Command executed.');
+ } catch (err) {
+ Chat.appendSystemMessage('Command error: ' + err.message);
+ }
+ },
+
+ // Show help with all available commands
+ showHelp() {
+ const lines = ['**Available Commands:**', ''];
+ for (const cmd of this.registry) {
+ const argHint = cmd.args ? ' ' + cmd.args : '';
+ lines.push('`/' + cmd.name + argHint + '` — ' + cmd.description);
+ }
+ lines.push('', 'Type a command or just chat normally.');
+ Chat.appendSystemMessage(lines.join('\n'));
+ },
+
+ // Clear messages in current conversation view
+ clearMessages() {
+ const messages = document.getElementById('messages');
+ const welcome = document.getElementById('welcome');
+ messages.innerHTML = '';
+ if (welcome) messages.appendChild(welcome);
+ Chat.showWelcome();
+ Chat.appendSystemMessage('Messages cleared.');
+ },
+
+ // Switch agent via sidebar
+ switchAgent(name) {
+ if (!name) {
+ Chat.appendSystemMessage('Usage: `/model ` — Available agents are in the sidebar dropdown.');
+ return;
+ }
+ const select = document.getElementById('agent-select');
+ const options = Array.from(select.options);
+ const match = options.find(o => o.value.toLowerCase() === name.toLowerCase());
+ if (!match) {
+ Chat.appendSystemMessage('Unknown agent: `' + name + '`. Available: ' +
+ options.map(o => '`' + o.value + '`').join(', '));
+ return;
+ }
+ select.value = match.value;
+ Sidebar.switchAgent(match.value);
+ Chat.appendSystemMessage('Switched to agent: `' + match.value + '`');
+ },
+
+ // Tab completion for command names
+ complete(partial) {
+ if (!partial.startsWith('/')) return null;
+
+ const typed = partial.slice(1).toLowerCase();
+ if (!typed) return null;
+
+ const matches = this.registry
+ .filter(c => c.name.startsWith(typed))
+ .map(c => '/' + c.name);
+
+ if (matches.length === 1) {
+ return matches[0] + ' ';
+ }
+ return null;
+ }
+};
diff --git a/internal/chat/templates/index.gohtml b/internal/chat/templates/index.gohtml
index 5caafb2..fc69b78 100644
--- a/internal/chat/templates/index.gohtml
+++ b/internal/chat/templates/index.gohtml
@@ -5,6 +5,7 @@
Nylas Chat
+
@@ -59,6 +60,7 @@
+
diff --git a/tests/chat/e2e/smoke.spec.js b/tests/chat/e2e/smoke.spec.js
new file mode 100644
index 0000000..d90828e
--- /dev/null
+++ b/tests/chat/e2e/smoke.spec.js
@@ -0,0 +1,116 @@
+// @ts-check
+const { test, expect } = require('@playwright/test');
+const sel = require('../../shared/helpers/chat-selectors');
+
+/**
+ * Smoke tests for Nylas Chat.
+ *
+ * Verifies the application loads correctly and all major
+ * UI elements are present and functional.
+ */
+
+test.describe('Smoke Tests', () => {
+ test.beforeEach(async ({ page }) => {
+ page.on('pageerror', (error) => {
+ console.error('Page error:', error.message);
+ });
+ await page.goto('/');
+ await expect(page.locator(sel.app)).toBeVisible();
+ });
+
+ test('page loads without JavaScript errors', async ({ page }) => {
+ const errors = [];
+ page.on('pageerror', (error) => errors.push(error.message));
+
+ await page.waitForLoadState('domcontentloaded');
+ await page.waitForTimeout(500);
+
+ const criticalErrors = errors.filter((e) => {
+ if (e.includes('Failed to load resource')) return false;
+ if (e.includes('404')) return false;
+ return true;
+ });
+
+ expect(criticalErrors).toHaveLength(0);
+ });
+
+ test('has correct page title', async ({ page }) => {
+ await expect(page).toHaveTitle('Nylas Chat');
+ });
+
+ test('has viewport meta tag', async ({ page }) => {
+ const viewport = page.locator('meta[name="viewport"]');
+ await expect(viewport).toHaveAttribute(
+ 'content',
+ expect.stringContaining('width=device-width')
+ );
+ });
+
+ test('sidebar is visible with header', async ({ page }) => {
+ const sidebar = page.locator(sel.sidebar.root);
+ await expect(sidebar).toBeVisible();
+
+ await expect(page.locator(sel.sidebar.title)).toHaveText('Nylas Chat');
+ await expect(page.locator(sel.sidebar.newChatBtn)).toBeVisible();
+ });
+
+ test('agent selector is present in sidebar', async ({ page }) => {
+ await expect(page.locator(sel.sidebar.agentLabel)).toBeVisible();
+ await expect(page.locator(sel.sidebar.agentSelect)).toBeVisible();
+ });
+
+ test('chat main area is visible', async ({ page }) => {
+ const main = page.locator(sel.chat.main);
+ await expect(main).toBeVisible();
+
+ await expect(page.locator(sel.chat.title)).toHaveText('New conversation');
+ // Toggle button is in DOM but hidden at desktop width (shown on mobile via media query)
+ await expect(page.locator(sel.chat.toggleSidebar)).toBeAttached();
+ });
+
+ test('welcome screen is displayed', async ({ page }) => {
+ const welcome = page.locator(sel.chat.welcome);
+ await expect(welcome).toBeVisible();
+
+ await expect(page.locator(sel.chat.welcomeTitle)).toHaveText(
+ 'Welcome to Nylas Chat'
+ );
+ });
+
+ test('suggestion buttons are present', async ({ page }) => {
+ const suggestions = page.locator(sel.chat.suggestionBtn);
+ await expect(suggestions).toHaveCount(3);
+
+ await expect(suggestions.nth(0)).toContainText('Unread emails');
+ await expect(suggestions.nth(1)).toContainText("Today's meetings");
+ await expect(suggestions.nth(2)).toContainText('Search emails');
+ });
+
+ test('input area is present and functional', async ({ page }) => {
+ const textarea = page.locator(sel.input.textarea);
+ await expect(textarea).toBeVisible();
+ await expect(textarea).toHaveAttribute(
+ 'placeholder',
+ expect.stringContaining('emails')
+ );
+
+ const sendBtn = page.locator(sel.input.sendBtn);
+ await expect(sendBtn).toBeVisible();
+ });
+
+ test('CSS files loaded correctly', async ({ page }) => {
+ const chatCss = page.locator('link[href="/css/chat.css"]');
+ await expect(chatCss).toBeAttached();
+
+ const componentsCss = page.locator('link[href="/css/components.css"]');
+ await expect(componentsCss).toBeAttached();
+ });
+
+ test('JavaScript files loaded correctly', async ({ page }) => {
+ const scripts = ['markdown.js', 'api.js', 'commands.js', 'sidebar.js', 'chat.js'];
+ for (const script of scripts) {
+ const el = page.locator(`script[src="/js/${script}"]`);
+ await expect(el).toBeAttached();
+ }
+ });
+});
diff --git a/tests/chat/e2e/ui-interactions.spec.js b/tests/chat/e2e/ui-interactions.spec.js
new file mode 100644
index 0000000..4a899e9
--- /dev/null
+++ b/tests/chat/e2e/ui-interactions.spec.js
@@ -0,0 +1,204 @@
+// @ts-check
+const { test, expect } = require('@playwright/test');
+const sel = require('../../shared/helpers/chat-selectors');
+
+/**
+ * UI interaction tests for Nylas Chat.
+ *
+ * Tests user interactions with the chat interface including
+ * input, sidebar, and slash commands.
+ */
+
+test.describe('Chat Input', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/');
+ await expect(page.locator(sel.app)).toBeVisible();
+ });
+
+ test('textarea is focused on load', async ({ page }) => {
+ const textarea = page.locator(sel.input.textarea);
+ await expect(textarea).toBeFocused();
+ });
+
+ test('textarea auto-resizes on input', async ({ page }) => {
+ const textarea = page.locator(sel.input.textarea);
+
+ const initialHeight = await textarea.evaluate((el) => el.offsetHeight);
+
+ // Type multiple lines
+ await textarea.fill('Line 1\nLine 2\nLine 3\nLine 4');
+ await textarea.dispatchEvent('input');
+
+ const expandedHeight = await textarea.evaluate((el) => el.offsetHeight);
+ expect(expandedHeight).toBeGreaterThanOrEqual(initialHeight);
+ });
+
+ test('empty message is not sent', async ({ page }) => {
+ const textarea = page.locator(sel.input.textarea);
+ await textarea.fill('');
+
+ await page.locator(sel.input.sendBtn).click();
+
+ // Welcome should still be visible (no message sent)
+ await expect(page.locator(sel.chat.welcome)).toBeVisible();
+ });
+
+ test('Shift+Enter creates newline without sending', async ({ page }) => {
+ const textarea = page.locator(sel.input.textarea);
+ await textarea.fill('Line 1');
+ await textarea.press('Shift+Enter');
+ await textarea.pressSequentially('Line 2');
+
+ const value = await textarea.inputValue();
+ expect(value).toContain('Line 1');
+ expect(value).toContain('Line 2');
+
+ // Welcome should still be visible (not sent)
+ await expect(page.locator(sel.chat.welcome)).toBeVisible();
+ });
+});
+
+test.describe('Sidebar', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/');
+ await expect(page.locator(sel.app)).toBeVisible();
+ });
+
+ test('new chat button resets conversation', async ({ page }) => {
+ // Click new chat
+ await page.locator(sel.sidebar.newChatBtn).click();
+
+ // Title should be reset
+ await expect(page.locator(sel.chat.title)).toHaveText('New conversation');
+ });
+
+ test('sidebar toggle button is in DOM', async ({ page }) => {
+ // Hidden at desktop width (display: none), visible only on mobile via media query
+ const toggleBtn = page.locator(sel.chat.toggleSidebar);
+ await expect(toggleBtn).toBeAttached();
+ await expect(toggleBtn).toHaveText('\u2630'); // hamburger icon
+ });
+
+ test('agent selector has options', async ({ page }) => {
+ const select = page.locator(sel.sidebar.agentSelect);
+ await expect(select).toBeVisible();
+
+ // Should have at least one option
+ const options = select.locator('option');
+ const count = await options.count();
+ expect(count).toBeGreaterThan(0);
+ });
+});
+
+test.describe('Slash Commands', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/');
+ await expect(page.locator(sel.app)).toBeVisible();
+ });
+
+ test('/help shows available commands', async ({ page }) => {
+ const textarea = page.locator(sel.input.textarea);
+ await textarea.fill('/help');
+ await textarea.press('Enter');
+
+ // Should show a system message with help content
+ const systemMsg = page.locator(sel.message.system);
+ await expect(systemMsg.first()).toBeVisible({ timeout: 5000 });
+
+ // Help content should mention available commands
+ const content = await systemMsg.first().textContent();
+ expect(content).toContain('help');
+ });
+
+ test('/clear clears the messages', async ({ page }) => {
+ const textarea = page.locator(sel.input.textarea);
+
+ // Send /help first to populate messages
+ await textarea.fill('/help');
+ await textarea.press('Enter');
+ await expect(page.locator(sel.message.system).first()).toBeVisible({ timeout: 5000 });
+
+ // Now clear
+ await textarea.fill('/clear');
+ await textarea.press('Enter');
+
+ // Old messages should be gone; only "Messages cleared." system message remains
+ await page.waitForTimeout(500);
+ const userAndAssistant = page.locator(`${sel.message.user}, ${sel.message.assistant}`);
+ await expect(userAndAssistant).toHaveCount(0);
+
+ // The "Messages cleared." confirmation should be shown
+ const systemMsg = page.locator(sel.message.system);
+ await expect(systemMsg).toHaveCount(1);
+ const content = await systemMsg.first().textContent();
+ expect(content).toContain('cleared');
+ });
+
+ test('/new resets to new conversation', async ({ page }) => {
+ const textarea = page.locator(sel.input.textarea);
+ await textarea.fill('/new');
+ await textarea.press('Enter');
+
+ // /new calls async API to create conversation, then resets UI
+ await expect(page.locator(sel.chat.title)).toHaveText('New conversation', { timeout: 5000 });
+ await expect(page.locator(sel.chat.welcome)).toBeVisible({ timeout: 5000 });
+ });
+
+ test('Tab completes slash commands', async ({ page }) => {
+ const textarea = page.locator(sel.input.textarea);
+ await textarea.fill('/hel');
+ await textarea.press('Tab');
+
+ const value = await textarea.inputValue();
+ // Tab completion appends a trailing space for args
+ expect(value.trim()).toBe('/help');
+ });
+
+ test('Tab completes partial command names', async ({ page }) => {
+ const textarea = page.locator(sel.input.textarea);
+ await textarea.fill('/cal');
+ await textarea.press('Tab');
+
+ const value = await textarea.inputValue();
+ expect(value.trim()).toBe('/calendar');
+ });
+});
+
+test.describe('API Health', () => {
+ test('health endpoint returns ok', async ({ request }) => {
+ const resp = await request.get('/api/health');
+ expect(resp.status()).toBe(200);
+
+ const body = await resp.json();
+ expect(body.status).toBe('ok');
+ });
+
+ test('config endpoint returns agent info', async ({ request }) => {
+ const resp = await request.get('/api/config');
+ expect(resp.status()).toBe(200);
+
+ const body = await resp.json();
+ expect(body.agent).toBeTruthy();
+ expect(body.available).toBeInstanceOf(Array);
+ expect(body.available.length).toBeGreaterThan(0);
+ });
+
+ test('conversations endpoint returns list', async ({ request }) => {
+ const resp = await request.get('/api/conversations');
+ expect(resp.status()).toBe(200);
+
+ // API returns a raw array of conversation summaries
+ const body = await resp.json();
+ expect(body).toBeInstanceOf(Array);
+ });
+
+ test('command endpoint rejects GET', async ({ request }) => {
+ const resp = await request.get('/api/command');
+ expect(resp.status()).toBe(405);
+ });
+
+ test('chat endpoint rejects GET', async ({ request }) => {
+ const resp = await request.get('/api/chat');
+ expect(resp.status()).toBe(405);
+ });
+});
diff --git a/tests/package.json b/tests/package.json
index 55a24e1..22ef4b8 100644
--- a/tests/package.json
+++ b/tests/package.json
@@ -8,6 +8,7 @@
"test:all": "playwright test",
"test:air": "playwright test --project=air-chromium",
"test:ui": "playwright test --project=ui-chromium",
+ "test:chat": "playwright test --project=chat-chromium",
"test:air:ui": "playwright test --project=air-chromium --ui",
"test:ui:ui": "playwright test --project=ui-chromium --ui",
"test:air:headed": "playwright test --project=air-chromium --headed",
diff --git a/tests/playwright.config.js b/tests/playwright.config.js
index 938d217..55b66dc 100644
--- a/tests/playwright.config.js
+++ b/tests/playwright.config.js
@@ -4,9 +4,10 @@ const { defineConfig, devices } = require('@playwright/test');
/**
* Playwright configuration for Nylas E2E tests.
*
- * Supports two test targets:
+ * Supports three test targets:
* - Air: Modern web email client (http://localhost:7365)
* - UI: Web-based CLI admin interface (http://localhost:7363)
+ * - Chat: AI chat interface (http://localhost:7367)
*
* @see https://playwright.dev/docs/test-configuration
*/
@@ -15,6 +16,7 @@ const { defineConfig, devices } = require('@playwright/test');
const isCI = !!process.env.CI;
const airPort = parseInt(process.env.AIR_PORT || '7365', 10);
const uiPort = parseInt(process.env.UI_PORT || '7363', 10);
+const chatPort = parseInt(process.env.CHAT_PORT || '7367', 10);
module.exports = defineConfig({
// Run tests in parallel within files
@@ -84,6 +86,24 @@ module.exports = defineConfig({
navigationTimeout: 30000,
},
},
+
+ // =========================================================================
+ // Nylas Chat (AI Chat Interface)
+ // =========================================================================
+ {
+ name: 'chat-chromium',
+ testDir: './chat/e2e',
+ use: {
+ ...devices['Desktop Chrome'],
+ baseURL: `http://localhost:${chatPort}`,
+ viewport: { width: 1280, height: 720 },
+ trace: 'on-first-retry',
+ screenshot: 'only-on-failure',
+ video: 'on-first-retry',
+ actionTimeout: 10000,
+ navigationTimeout: 30000,
+ },
+ },
],
// Web server configurations
@@ -105,5 +125,12 @@ module.exports = defineConfig({
timeout: 60000,
reuseExistingServer: !isCI,
},
+ // Nylas Chat server (port 7367)
+ {
+ command: 'cd .. && go run cmd/nylas/main.go chat --no-browser --port ' + chatPort,
+ port: chatPort,
+ timeout: 60000,
+ reuseExistingServer: !isCI,
+ },
],
});
diff --git a/tests/shared/helpers/chat-selectors.js b/tests/shared/helpers/chat-selectors.js
new file mode 100644
index 0000000..7b1fa5d
--- /dev/null
+++ b/tests/shared/helpers/chat-selectors.js
@@ -0,0 +1,54 @@
+/**
+ * Semantic selectors for Nylas Chat E2E tests.
+ * Uses IDs and semantic attributes from index.gohtml and chat.js.
+ */
+module.exports = {
+ app: '.app',
+ sidebar: {
+ root: '#sidebar',
+ header: '.sidebar-header',
+ title: '.sidebar-header h2',
+ newChatBtn: '#btn-new-chat',
+ conversationList: '#conversation-list',
+ agentSelect: '#agent-select',
+ agentLabel: '.agent-label',
+ },
+ chat: {
+ main: '.chat-main',
+ header: '#chat-header',
+ title: '#chat-title',
+ toggleSidebar: '#btn-toggle-sidebar',
+ messages: '#messages',
+ welcome: '#welcome',
+ welcomeTitle: '#welcome h2',
+ suggestions: '.suggestions',
+ suggestionBtn: '.suggestion',
+ },
+ input: {
+ form: '#chat-form',
+ textarea: '#chat-input',
+ sendBtn: '#btn-send',
+ },
+ message: {
+ user: '.message.user',
+ assistant: '.message.assistant',
+ system: '.message.system',
+ content: '.message-content',
+ streaming: '.message.streaming',
+ },
+ tool: {
+ indicator: '.tool-indicator',
+ name: '.tool-name',
+ details: '.tool-details',
+ },
+ approval: {
+ card: '.approval-card',
+ header: '.approval-header',
+ preview: '.approval-preview',
+ approveBtn: '.btn-approve',
+ rejectBtn: '.btn-reject',
+ status: '.approval-status',
+ resolved: '.approval-card.resolved',
+ },
+ thinking: '.thinking',
+};
From 1fcf595bc58728951cd5ffadd34caed77a7aa65e Mon Sep 17 00:00:00 2001
From: Qasim
Date: Fri, 13 Feb 2026 07:12:26 -0500
Subject: [PATCH 3/9] feat(api): auto-paginate when --limit exceeds API max
(200)
When --limit is set above 200, automatically switch to cursor-based
pagination instead of returning an API error. This applies to email
list, calendar events list, and contacts list commands.
Also extracts MaxAPILimit constant to common/pagination.go, replaces
custom contains() helper with slices.Contains, optimizes page size
to use the full API limit (200) instead of hardcoded 50, and
refactors contacts list to use common.WithClient.
---
internal/cli/calendar/events_list.go | 44 +++++++-
internal/cli/common/pagination.go | 3 +
internal/cli/contacts/list.go | 149 ++++++++++++++++-----------
internal/cli/email/helpers_test.go | 65 ------------
internal/cli/email/list.go | 41 ++++----
5 files changed, 153 insertions(+), 149 deletions(-)
diff --git a/internal/cli/calendar/events_list.go b/internal/cli/calendar/events_list.go
index e3472fa..8538571 100644
--- a/internal/cli/calendar/events_list.go
+++ b/internal/cli/calendar/events_list.go
@@ -54,6 +54,13 @@ Examples:
}
}
+ // Auto-paginate when limit exceeds API maximum
+ maxItems := 0
+ if limit > common.MaxAPILimit {
+ maxItems = limit
+ limit = common.MaxAPILimit
+ }
+
_, err := common.WithClient(args, func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) {
// If no calendar specified, try to get the primary calendar
calID, err := GetDefaultCalendarID(ctx, client, grantID, calendarID, false)
@@ -77,9 +84,38 @@ Examples:
params.ShowCancelled = true
}
- events, err := client.GetEvents(ctx, grantID, calID, params)
- if err != nil {
- return struct{}{}, common.WrapListError("events", err)
+ var events []domain.Event
+ if maxItems > 0 {
+ // Paginated fetch for large limits
+ pageSize := min(limit, common.MaxAPILimit)
+ params.Limit = pageSize
+
+ fetcher := func(ctx context.Context, cursor string) (common.PageResult[domain.Event], error) {
+ params.PageToken = cursor
+ resp, err := client.GetEventsWithCursor(ctx, grantID, calID, params)
+ if err != nil {
+ return common.PageResult[domain.Event]{}, err
+ }
+ return common.PageResult[domain.Event]{
+ Data: resp.Data,
+ NextCursor: resp.Pagination.NextCursor,
+ }, nil
+ }
+
+ config := common.DefaultPaginationConfig()
+ config.PageSize = pageSize
+ config.MaxItems = maxItems
+
+ events, err = common.FetchAllPages(ctx, config, fetcher)
+ if err != nil {
+ return struct{}{}, common.WrapListError("events", err)
+ }
+ } else {
+ // Standard single-page fetch
+ events, err = client.GetEvents(ctx, grantID, calID, params)
+ if err != nil {
+ return struct{}{}, common.WrapListError("events", err)
+ }
}
// JSON output (including empty array)
@@ -179,7 +215,7 @@ Examples:
}
cmd.Flags().StringVarP(&calendarID, "calendar", "c", "", "Calendar ID (defaults to primary)")
- cmd.Flags().IntVarP(&limit, "limit", "n", 10, "Maximum number of events to show")
+ cmd.Flags().IntVarP(&limit, "limit", "n", 10, "Maximum number of events to show (auto-paginates if >200)")
cmd.Flags().IntVarP(&days, "days", "d", 7, "Show events for the next N days (0 for no limit)")
cmd.Flags().BoolVar(&showAll, "show-cancelled", false, "Include cancelled events")
cmd.Flags().StringVar(&targetTZ, "timezone", "", "Display times in this timezone (e.g., America/Los_Angeles). Defaults to local timezone.")
diff --git a/internal/cli/common/pagination.go b/internal/cli/common/pagination.go
index 188f808..5325abb 100644
--- a/internal/cli/common/pagination.go
+++ b/internal/cli/common/pagination.go
@@ -7,6 +7,9 @@ import (
"os"
)
+// MaxAPILimit is the maximum number of items the Nylas API returns per request.
+const MaxAPILimit = 200
+
// PageResult represents a paginated API response.
type PageResult[T any] struct {
Data []T // The items in this page
diff --git a/internal/cli/contacts/list.go b/internal/cli/contacts/list.go
index 3c90e41..86d0727 100644
--- a/internal/cli/contacts/list.go
+++ b/internal/cli/contacts/list.go
@@ -25,6 +25,13 @@ func newListCmd() *cobra.Command {
Long: "List all contacts for the specified grant or default account.",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
+ // Auto-paginate when limit exceeds API maximum
+ maxItems := 0
+ if limit > common.MaxAPILimit {
+ maxItems = limit
+ limit = common.MaxAPILimit
+ }
+
// Check if we should use structured output (JSON/YAML/quiet)
if common.IsJSON(cmd) {
_, err := common.WithClient(args, func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) {
@@ -34,7 +41,7 @@ func newListCmd() *cobra.Command {
Source: source,
}
- contacts, err := client.GetContacts(ctx, grantID, params)
+ contacts, err := fetchContacts(ctx, client, grantID, params, maxItems)
if err != nil {
return struct{}{}, common.WrapListError("contacts", err)
}
@@ -46,81 +53,99 @@ func newListCmd() *cobra.Command {
}
// Traditional table output
- client, err := common.GetNylasClient()
- if err != nil {
- return err
- }
-
- grantID, err := common.GetGrantID(args)
- if err != nil {
- return err
- }
-
- ctx, cancel := common.CreateContext()
- defer cancel()
-
- params := &domain.ContactQueryParams{
- Limit: limit,
- Email: email,
- Source: source,
- }
-
- contacts, err := client.GetContacts(ctx, grantID, params)
- if err != nil {
- return common.WrapListError("contacts", err)
- }
-
- if len(contacts) == 0 {
- common.PrintEmptyState("contacts")
- return nil
- }
+ _, err := common.WithClient(args, func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) {
+ params := &domain.ContactQueryParams{
+ Limit: limit,
+ Email: email,
+ Source: source,
+ }
- fmt.Printf("Found %d contact(s):\n\n", len(contacts))
+ contacts, err := fetchContacts(ctx, client, grantID, params, maxItems)
+ if err != nil {
+ return struct{}{}, common.WrapListError("contacts", err)
+ }
- var table *common.Table
- if showID {
- table = common.NewTable("ID", "NAME", "EMAIL", "PHONE", "COMPANY")
- } else {
- table = common.NewTable("NAME", "EMAIL", "PHONE", "COMPANY")
- }
- for _, contact := range contacts {
- name := contact.DisplayName()
- email := contact.PrimaryEmail()
- phone := contact.PrimaryPhone()
- company := contact.CompanyName
- if contact.JobTitle != "" && company != "" {
- company = fmt.Sprintf("%s - %s", contact.JobTitle, company)
- } else if contact.JobTitle != "" {
- company = contact.JobTitle
+ if len(contacts) == 0 {
+ common.PrintEmptyState("contacts")
+ return struct{}{}, nil
}
+ fmt.Printf("Found %d contact(s):\n\n", len(contacts))
+
+ var table *common.Table
if showID {
- table.AddRow(
- common.Dim.Sprint(contact.ID),
- common.Cyan.Sprint(name),
- email,
- phone,
- common.Dim.Sprint(company),
- )
+ table = common.NewTable("ID", "NAME", "EMAIL", "PHONE", "COMPANY")
} else {
- table.AddRow(
- common.Cyan.Sprint(name),
- email,
- phone,
- common.Dim.Sprint(company),
- )
+ table = common.NewTable("NAME", "EMAIL", "PHONE", "COMPANY")
}
- }
- table.Render()
+ for _, contact := range contacts {
+ name := contact.DisplayName()
+ emailAddr := contact.PrimaryEmail()
+ phone := contact.PrimaryPhone()
+ company := contact.CompanyName
+ if contact.JobTitle != "" && company != "" {
+ company = fmt.Sprintf("%s - %s", contact.JobTitle, company)
+ } else if contact.JobTitle != "" {
+ company = contact.JobTitle
+ }
- return nil
+ if showID {
+ table.AddRow(
+ common.Dim.Sprint(contact.ID),
+ common.Cyan.Sprint(name),
+ emailAddr,
+ phone,
+ common.Dim.Sprint(company),
+ )
+ } else {
+ table.AddRow(
+ common.Cyan.Sprint(name),
+ emailAddr,
+ phone,
+ common.Dim.Sprint(company),
+ )
+ }
+ }
+ table.Render()
+
+ return struct{}{}, nil
+ })
+ return err
},
}
- cmd.Flags().IntVarP(&limit, "limit", "n", 50, "Maximum number of contacts to show")
+ cmd.Flags().IntVarP(&limit, "limit", "n", 50, "Maximum number of contacts to show (auto-paginates if >200)")
cmd.Flags().StringVarP(&email, "email", "e", "", "Filter by email address")
cmd.Flags().StringVarP(&source, "source", "s", "", "Filter by source (address_book, inbox, domain)")
cmd.Flags().BoolVar(&showID, "id", false, "Show contact IDs")
return cmd
}
+
+// fetchContacts retrieves contacts, using pagination when maxItems > 0.
+func fetchContacts(ctx context.Context, client ports.NylasClient, grantID string, params *domain.ContactQueryParams, maxItems int) ([]domain.Contact, error) {
+ if maxItems > 0 {
+ pageSize := min(params.Limit, common.MaxAPILimit)
+ params.Limit = pageSize
+
+ fetcher := func(ctx context.Context, cursor string) (common.PageResult[domain.Contact], error) {
+ params.PageToken = cursor
+ resp, err := client.GetContactsWithCursor(ctx, grantID, params)
+ if err != nil {
+ return common.PageResult[domain.Contact]{}, err
+ }
+ return common.PageResult[domain.Contact]{
+ Data: resp.Data,
+ NextCursor: resp.Pagination.NextCursor,
+ }, nil
+ }
+
+ config := common.DefaultPaginationConfig()
+ config.PageSize = pageSize
+ config.MaxItems = maxItems
+
+ return common.FetchAllPages(ctx, config, fetcher)
+ }
+
+ return client.GetContacts(ctx, grantID, params)
+}
diff --git a/internal/cli/email/helpers_test.go b/internal/cli/email/helpers_test.go
index 403f03c..ceb6c0b 100644
--- a/internal/cli/email/helpers_test.go
+++ b/internal/cli/email/helpers_test.go
@@ -310,71 +310,6 @@ func TestRemoveTagWithContent(t *testing.T) {
}
}
-func TestContains(t *testing.T) {
- tests := []struct {
- name string
- slice []string
- item string
- expected bool
- }{
- {
- name: "item present",
- slice: []string{"a", "b", "c"},
- item: "b",
- expected: true,
- },
- {
- name: "item not present",
- slice: []string{"a", "b", "c"},
- item: "d",
- expected: false,
- },
- {
- name: "empty slice",
- slice: []string{},
- item: "a",
- expected: false,
- },
- {
- name: "nil slice",
- slice: nil,
- item: "a",
- expected: false,
- },
- {
- name: "first item",
- slice: []string{"first", "second"},
- item: "first",
- expected: true,
- },
- {
- name: "last item",
- slice: []string{"first", "last"},
- item: "last",
- expected: true,
- },
- {
- name: "case sensitive",
- slice: []string{"Hello"},
- item: "hello",
- expected: false,
- },
- {
- name: "empty string in slice",
- slice: []string{"", "a"},
- item: "",
- expected: true,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := contains(tt.slice, tt.item)
- assert.Equal(t, tt.expected, result)
- })
- }
-}
-
func TestPrintMetadataInfo(t *testing.T) {
// Just verify it doesn't panic
printMetadataInfo()
diff --git a/internal/cli/email/list.go b/internal/cli/email/list.go
index 8e4c08b..cad1d4a 100644
--- a/internal/cli/email/list.go
+++ b/internal/cli/email/list.go
@@ -3,6 +3,7 @@ package email
import (
"context"
"fmt"
+ "slices"
"strings"
"github.com/nylas/cli/internal/cli/common"
@@ -54,6 +55,13 @@ Use --max to limit total messages when using --all.`,
return runListStructured(cmd, args, limit, unread, starred, from, folder, allFolders, all, maxItems, metadataPair)
}
+ // Auto-paginate when limit exceeds API maximum
+ if limit > common.MaxAPILimit && !all {
+ all = true
+ maxItems = limit
+ limit = common.MaxAPILimit
+ }
+
// Traditional formatted output
_, err := common.WithClient(args, func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) {
params := &domain.MessageQueryParams{
@@ -107,9 +115,9 @@ Use --max to limit total messages when using --all.`,
if all {
// Use pagination to fetch all messages
- pageSize := 50 // Optimal page size for API
- if limit > 0 && limit < pageSize {
- pageSize = limit
+ pageSize := min(limit, common.MaxAPILimit)
+ if pageSize <= 0 {
+ pageSize = common.MaxAPILimit
}
params.Limit = pageSize
@@ -157,7 +165,7 @@ Use --max to limit total messages when using --all.`,
},
}
- cmd.Flags().IntVarP(&limit, "limit", "l", 10, "Number of messages to fetch (per page with --all)")
+ cmd.Flags().IntVarP(&limit, "limit", "l", 10, "Number of messages to fetch (auto-paginates if >200)")
cmd.Flags().BoolVarP(&unread, "unread", "u", false, "Only show unread messages")
cmd.Flags().BoolVarP(&starred, "starred", "s", false, "Only show starred messages")
cmd.Flags().StringVarP(&from, "from", "f", "", "Filter by sender email")
@@ -198,7 +206,7 @@ func resolveFolderName(ctx context.Context, client ports.NylasClient, grantID, f
// Find matching aliases for the search name
var searchAliases []string
for key, aliases := range nameAliases {
- if key == searchName || contains(aliases, searchName) {
+ if key == searchName || slices.Contains(aliases, searchName) {
searchAliases = aliases
break
}
@@ -221,20 +229,17 @@ func resolveFolderName(ctx context.Context, client ports.NylasClient, grantID, f
return "", nil
}
-// contains checks if a slice contains a string.
-func contains(slice []string, item string) bool {
- for _, s := range slice {
- if s == item {
- return true
- }
- }
- return false
-}
-
// runListStructured handles structured output (JSON/YAML/quiet) for the list command.
func runListStructured(cmd *cobra.Command, args []string, limit int, unread, starred bool,
from, folder string, allFolders, all bool, maxItems int, metadataPair string) error {
+ // Auto-paginate when limit exceeds API maximum
+ if limit > common.MaxAPILimit && !all {
+ all = true
+ maxItems = limit
+ limit = common.MaxAPILimit
+ }
+
_, err := common.WithClient(args, func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) {
params := &domain.MessageQueryParams{
Limit: limit,
@@ -280,9 +285,9 @@ func runListStructured(cmd *cobra.Command, args []string, limit int, unread, sta
var err error
if all {
- pageSize := 50
- if limit > 0 && limit < pageSize {
- pageSize = limit
+ pageSize := min(limit, common.MaxAPILimit)
+ if pageSize <= 0 {
+ pageSize = common.MaxAPILimit
}
params.Limit = pageSize
From 41b3f498696dc3c43dd22068f256a8c2d3c4f375 Mon Sep 17 00:00:00 2001
From: Qasim
Date: Fri, 13 Feb 2026 07:12:57 -0500
Subject: [PATCH 4/9] update gitignore
---
.gitignore | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/.gitignore b/.gitignore
index d68a1ed..b8907e5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,8 +2,6 @@
*plan.md
# Temporary refactoring/analysis documents
-REFACTORING_PROGRESS.md
-ENGINEERING_IMPROVEMENTS_SUMMARY.md
COVERAGE_REPORT.md
start.md
*_PROGRESS.md
@@ -26,6 +24,7 @@ CLAUDE.local.md
.claude/settings.local.json
.claude/*.local.*
.claude/*/*.local.*
+*.session
# Session-specific progress tracking (personal, not shared)
claude-progress.txt
From ea170e95f125817dc54698002fc5c50b25d463f7 Mon Sep 17 00:00:00 2001
From: Qasim
Date: Fri, 13 Feb 2026 07:48:23 -0500
Subject: [PATCH 5/9] refactor(cli): remove duplicated boilerplate across
packages
- Replace custom contains()/findSubstring() with strings.Contains()
- Replace getScopesClient()/getProvidersClient() with common.GetNylasClient()
- Add withSlackClient()/getSlackClientOrError() helpers to reduce slack boilerplate
- Add common.IsStructuredOutput() to replace repeated 3-line format check pattern
- Fix folder list UNREAD column alignment when ANSI colors are applied
---
internal/cli/auth/providers.go | 45 +-------------
internal/cli/auth/scopes.go | 96 ++++++------------------------
internal/cli/common/output.go | 10 ++++
internal/cli/config/get_test.go | 18 +-----
internal/cli/config/gpg_test.go | 3 +-
internal/cli/config/set_test.go | 5 +-
internal/cli/email/folders.go | 12 ++--
internal/cli/slack/auth.go | 4 +-
internal/cli/slack/channel_info.go | 83 ++++++++++++--------------
internal/cli/slack/channels.go | 11 +---
internal/cli/slack/files.go | 29 +++------
internal/cli/slack/helpers.go | 24 ++++++++
internal/cli/slack/messages.go | 11 +---
internal/cli/slack/search.go | 11 +---
internal/cli/slack/send.go | 14 ++---
internal/cli/slack/users.go | 22 ++-----
16 files changed, 132 insertions(+), 266 deletions(-)
diff --git a/internal/cli/auth/providers.go b/internal/cli/auth/providers.go
index 0db7dd0..49088c8 100644
--- a/internal/cli/auth/providers.go
+++ b/internal/cli/auth/providers.go
@@ -3,16 +3,10 @@ package auth
import (
"encoding/json"
"fmt"
- "os"
"github.com/spf13/cobra"
- "github.com/nylas/cli/internal/adapters/config"
- "github.com/nylas/cli/internal/adapters/keyring"
- "github.com/nylas/cli/internal/adapters/nylas"
"github.com/nylas/cli/internal/cli/common"
- "github.com/nylas/cli/internal/domain"
- "github.com/nylas/cli/internal/ports"
)
func newProvidersCmd() *cobra.Command {
@@ -40,7 +34,7 @@ This command shows connectors configured for your Nylas application.`,
ctx, cancel := common.CreateContext()
defer cancel()
- client, err := getProvidersClient()
+ client, err := common.GetNylasClient()
if err != nil {
return err
}
@@ -84,40 +78,3 @@ This command shows connectors configured for your Nylas application.`,
return cmd
}
-
-func getProvidersClient() (ports.NylasClient, error) {
- configStore := config.NewDefaultFileStore()
- cfg, err := configStore.Load()
- if err != nil {
- cfg = &domain.Config{Region: "us"}
- }
-
- // Check environment variables first (highest priority)
- apiKey := os.Getenv("NYLAS_API_KEY")
- clientID := os.Getenv("NYLAS_CLIENT_ID")
- clientSecret := os.Getenv("NYLAS_CLIENT_SECRET")
-
- // If API key not in env, try keyring/file store
- if apiKey == "" {
- secretStore, err := keyring.NewSecretStore(config.DefaultConfigDir())
- if err == nil {
- apiKey, _ = secretStore.Get(ports.KeyAPIKey)
- if clientID == "" {
- clientID, _ = secretStore.Get(ports.KeyClientID)
- }
- if clientSecret == "" {
- clientSecret, _ = secretStore.Get(ports.KeyClientSecret)
- }
- }
- }
-
- if apiKey == "" {
- return nil, fmt.Errorf("API key not configured. Set NYLAS_API_KEY environment variable or run 'nylas auth config'")
- }
-
- c := nylas.NewHTTPClient()
- c.SetRegion(cfg.Region)
- c.SetCredentials(clientID, clientSecret, apiKey)
-
- return c, nil
-}
diff --git a/internal/cli/auth/scopes.go b/internal/cli/auth/scopes.go
index ddb166c..4fc3985 100644
--- a/internal/cli/auth/scopes.go
+++ b/internal/cli/auth/scopes.go
@@ -3,16 +3,12 @@ package auth
import (
"encoding/json"
"fmt"
- "os"
+ "strings"
"github.com/spf13/cobra"
"github.com/nylas/cli/internal/adapters/config"
- "github.com/nylas/cli/internal/adapters/keyring"
- "github.com/nylas/cli/internal/adapters/nylas"
"github.com/nylas/cli/internal/cli/common"
- "github.com/nylas/cli/internal/domain"
- "github.com/nylas/cli/internal/ports"
)
func newScopesCmd() *cobra.Command {
@@ -42,7 +38,7 @@ If no grant ID is provided, shows scopes for the currently active grant.`,
ctx, cancel := common.CreateContext()
defer cancel()
- client, err := getScopesClient()
+ client, err := common.GetNylasClient()
if err != nil {
return err
}
@@ -121,118 +117,64 @@ If no grant ID is provided, shows scopes for the currently active grant.`,
// describeScopeCategory provides a brief description for common scope patterns
func describeScopeCategory(scope string) string {
// Google scopes
- if contains(scope, "gmail") {
- if contains(scope, "readonly") {
+ if strings.Contains(scope, "gmail") {
+ if strings.Contains(scope, "readonly") {
return "→ Read-only access to Gmail"
}
- if contains(scope, "send") {
+ if strings.Contains(scope, "send") {
return "→ Send email via Gmail"
}
- if contains(scope, "modify") || contains(scope, "compose") {
+ if strings.Contains(scope, "modify") || strings.Contains(scope, "compose") {
return "→ Read and modify Gmail messages"
}
return "→ Gmail access"
}
- if contains(scope, "calendar") {
- if contains(scope, "readonly") {
+ if strings.Contains(scope, "calendar") {
+ if strings.Contains(scope, "readonly") {
return "→ Read-only access to Google Calendar"
}
- if contains(scope, "events") {
+ if strings.Contains(scope, "events") {
return "→ Manage calendar events"
}
return "→ Calendar access"
}
- if contains(scope, "contacts") {
- if contains(scope, "readonly") {
+ if strings.Contains(scope, "contacts") {
+ if strings.Contains(scope, "readonly") {
return "→ Read-only access to contacts"
}
return "→ Manage contacts"
}
// Microsoft scopes
- if contains(scope, "Mail.") {
- if contains(scope, "Read") {
+ if strings.Contains(scope, "Mail.") {
+ if strings.Contains(scope, "Read") {
return "→ Read email messages"
}
- if contains(scope, "Send") {
+ if strings.Contains(scope, "Send") {
return "→ Send email messages"
}
return "→ Email access"
}
- if contains(scope, "Calendars.") {
- if contains(scope, "Read") {
+ if strings.Contains(scope, "Calendars.") {
+ if strings.Contains(scope, "Read") {
return "→ Read calendar events"
}
return "→ Manage calendar events"
}
- if contains(scope, "Contacts.") {
- if contains(scope, "Read") {
+ if strings.Contains(scope, "Contacts.") {
+ if strings.Contains(scope, "Read") {
return "→ Read contacts"
}
return "→ Manage contacts"
}
- if contains(scope, "User.Read") {
+ if strings.Contains(scope, "User.Read") {
return "→ Read user profile information"
}
return ""
}
-
-func contains(s, substr string) bool {
- return len(s) >= len(substr) && (s == substr ||
- (len(s) > len(substr) && (s[:len(substr)] == substr ||
- s[len(s)-len(substr):] == substr ||
- s[max(0, len(s)-len(substr)-1):len(s)-len(substr)] == substr ||
- findSubstring(s, substr))))
-}
-
-func findSubstring(s, substr string) bool {
- for i := 0; i <= len(s)-len(substr); i++ {
- if s[i:i+len(substr)] == substr {
- return true
- }
- }
- return false
-}
-
-func getScopesClient() (ports.NylasClient, error) {
- configStore := config.NewDefaultFileStore()
- cfg, err := configStore.Load()
- if err != nil {
- cfg = &domain.Config{Region: "us"}
- }
-
- // Check environment variables first (highest priority)
- apiKey := os.Getenv("NYLAS_API_KEY")
- clientID := os.Getenv("NYLAS_CLIENT_ID")
- clientSecret := os.Getenv("NYLAS_CLIENT_SECRET")
-
- // If API key not in env, try keyring/file store
- if apiKey == "" {
- secretStore, err := keyring.NewSecretStore(config.DefaultConfigDir())
- if err == nil {
- apiKey, _ = secretStore.Get(ports.KeyAPIKey)
- if clientID == "" {
- clientID, _ = secretStore.Get(ports.KeyClientID)
- }
- if clientSecret == "" {
- clientSecret, _ = secretStore.Get(ports.KeyClientSecret)
- }
- }
- }
-
- if apiKey == "" {
- return nil, fmt.Errorf("API key not configured. Set NYLAS_API_KEY environment variable or run 'nylas auth config'")
- }
-
- c := nylas.NewHTTPClient()
- c.SetRegion(cfg.Region)
- c.SetCredentials(clientID, clientSecret, apiKey)
-
- return c, nil
-}
diff --git a/internal/cli/common/output.go b/internal/cli/common/output.go
index dac20fd..e070932 100644
--- a/internal/cli/common/output.go
+++ b/internal/cli/common/output.go
@@ -79,6 +79,16 @@ func IsJSON(cmd *cobra.Command) bool {
return format == "json"
}
+// IsStructuredOutput returns true if non-table output is requested (JSON, YAML, or quiet).
+func IsStructuredOutput(cmd *cobra.Command) bool {
+ if IsJSON(cmd) {
+ return true
+ }
+ format, _ := cmd.Flags().GetString("format")
+ quiet, _ := cmd.Flags().GetBool("quiet")
+ return format == "yaml" || format == "quiet" || quiet
+}
+
// IsWide returns true if wide output mode is enabled
func IsWide(cmd *cobra.Command) bool {
wide, _ := cmd.Flags().GetBool("wide")
diff --git a/internal/cli/config/get_test.go b/internal/cli/config/get_test.go
index c0b84da..707b0b0 100644
--- a/internal/cli/config/get_test.go
+++ b/internal/cli/config/get_test.go
@@ -1,6 +1,7 @@
package config
import (
+ "strings"
"testing"
)
@@ -167,7 +168,7 @@ func TestGetConfigValue(t *testing.T) {
t.Errorf("getConfigValue() expected error containing %q, got nil", tt.errMsg)
return
}
- if tt.errMsg != "" && !contains(err.Error(), tt.errMsg) {
+ if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("getConfigValue() error = %q, want error containing %q", err.Error(), tt.errMsg)
}
return
@@ -194,18 +195,3 @@ func TestGetConfigValue_WithNonStruct(t *testing.T) {
t.Error("getConfigValue() with non-struct should return error, got nil")
}
}
-
-// Helper function to check if a string contains a substring
-func contains(s, substr string) bool {
- return len(substr) > 0 && len(s) >= len(substr) && s[:len(substr)] == substr ||
- len(s) > len(substr) && containsHelper(s, substr)
-}
-
-func containsHelper(s, substr string) bool {
- for i := 0; i <= len(s)-len(substr); i++ {
- if s[i:i+len(substr)] == substr {
- return true
- }
- }
- return false
-}
diff --git a/internal/cli/config/gpg_test.go b/internal/cli/config/gpg_test.go
index 4f79f84..f6b098e 100644
--- a/internal/cli/config/gpg_test.go
+++ b/internal/cli/config/gpg_test.go
@@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
+ "strings"
"testing"
"github.com/nylas/cli/internal/adapters/config"
@@ -182,7 +183,7 @@ func TestGPGConfig_FileFormat(t *testing.T) {
}
for _, line := range expectedLines {
- if !contains(content, line) {
+ if !strings.Contains(content, line) {
t.Errorf("config file missing expected line: %s\nGot:\n%s", line, content)
}
}
diff --git a/internal/cli/config/set_test.go b/internal/cli/config/set_test.go
index 92a27c9..791c247 100644
--- a/internal/cli/config/set_test.go
+++ b/internal/cli/config/set_test.go
@@ -2,6 +2,7 @@ package config
import (
"reflect"
+ "strings"
"testing"
)
@@ -109,7 +110,7 @@ func TestSetFieldValue(t *testing.T) {
t.Errorf("setFieldValue() expected error containing %q, got nil", tt.errMsg)
return
}
- if tt.errMsg != "" && !contains(err.Error(), tt.errMsg) {
+ if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("setFieldValue() error = %q, want error containing %q", err.Error(), tt.errMsg)
}
return
@@ -250,7 +251,7 @@ func TestSetConfigValue(t *testing.T) {
t.Errorf("setConfigValue() expected error containing %q, got nil", tt.errMsg)
return
}
- if tt.errMsg != "" && !contains(err.Error(), tt.errMsg) {
+ if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("setConfigValue() error = %q, want error containing %q", err.Error(), tt.errMsg)
}
return
diff --git a/internal/cli/email/folders.go b/internal/cli/email/folders.go
index ee4ab6e..266e8b1 100644
--- a/internal/cli/email/folders.go
+++ b/internal/cli/email/folders.go
@@ -73,17 +73,17 @@ func newFoldersListCmd() *cobra.Command {
name = name[:25] + "..."
}
- unreadStr := fmt.Sprintf("%d", f.UnreadCount)
+ unreadPadded := fmt.Sprintf("%8d", f.UnreadCount)
if f.UnreadCount > 0 {
- unreadStr = common.Cyan.Sprintf("%d", f.UnreadCount)
+ unreadPadded = common.Cyan.Sprint(unreadPadded)
}
if showID {
- fmt.Printf("%-36s %-30s %-12s %8d %8s\n",
- common.Dim.Sprint(f.ID), name, folderType, f.TotalCount, unreadStr)
+ fmt.Printf("%-36s %-30s %-12s %8d %s\n",
+ common.Dim.Sprint(f.ID), name, folderType, f.TotalCount, unreadPadded)
} else {
- fmt.Printf("%-30s %-12s %8d %8s\n",
- name, folderType, f.TotalCount, unreadStr)
+ fmt.Printf("%-30s %-12s %8d %s\n",
+ name, folderType, f.TotalCount, unreadPadded)
}
}
diff --git a/internal/cli/slack/auth.go b/internal/cli/slack/auth.go
index 83053b4..7540cad 100644
--- a/internal/cli/slack/auth.go
+++ b/internal/cli/slack/auth.go
@@ -106,9 +106,7 @@ func newAuthStatusCmd() *cobra.Command {
}
// Handle structured output (JSON/YAML/quiet)
- format, _ := cmd.Flags().GetString("format")
- quiet, _ := cmd.Flags().GetBool("quiet")
- if common.IsJSON(cmd) || format == "yaml" || quiet {
+ if common.IsStructuredOutput(cmd) {
out := common.GetOutputWriter(cmd)
return out.Write(auth)
}
diff --git a/internal/cli/slack/channel_info.go b/internal/cli/slack/channel_info.go
index a775966..9020567 100644
--- a/internal/cli/slack/channel_info.go
+++ b/internal/cli/slack/channel_info.go
@@ -3,11 +3,13 @@
package slack
import (
+ "context"
"fmt"
"github.com/spf13/cobra"
"github.com/nylas/cli/internal/cli/common"
+ "github.com/nylas/cli/internal/ports"
)
// newChannelInfoCmd creates the info subcommand for getting channel details.
@@ -17,52 +19,41 @@ func newChannelInfoCmd() *cobra.Command {
Short: "Get detailed info about a channel",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
- client, err := getSlackClientFromKeyring()
- if err != nil {
- return common.NewUserError(
- "not authenticated with Slack",
- "Run: nylas slack auth set --token YOUR_TOKEN",
- )
- }
-
- ctx, cancel := common.CreateContext()
- defer cancel()
-
- channelID := args[0]
- ch, err := client.GetChannel(ctx, channelID)
- if err != nil {
- return common.WrapGetError("channel", err)
- }
-
- // Handle structured output (JSON/YAML/quiet)
- format, _ := cmd.Flags().GetString("format")
- quiet, _ := cmd.Flags().GetBool("quiet")
- if common.IsJSON(cmd) || format == "yaml" || quiet {
- out := common.GetOutputWriter(cmd)
- return out.Write(ch)
- }
-
- _, _ = common.Cyan.Printf("Channel: #%s\n", ch.Name)
- fmt.Printf(" ID: %s\n", ch.ID)
- fmt.Printf(" Is Channel: %v\n", ch.IsChannel)
- fmt.Printf(" Is Private: %v\n", ch.IsPrivate)
- fmt.Printf(" Is Archived: %v\n", ch.IsArchived)
- fmt.Printf(" Is Member: %v\n", ch.IsMember)
- fmt.Printf(" Is Shared: %v\n", ch.IsShared)
- fmt.Printf(" Is OrgShared: %v\n", ch.IsOrgShared)
- fmt.Printf(" Is ExtShared: %v\n", ch.IsExtShared)
- fmt.Printf(" Is IM: %v\n", ch.IsIM)
- fmt.Printf(" Is MPIM: %v\n", ch.IsMPIM)
- fmt.Printf(" Is Group: %v\n", ch.IsGroup)
- fmt.Printf(" Members: %d\n", ch.MemberCount)
- if ch.Purpose != "" {
- _, _ = common.Dim.Printf(" Purpose: %s\n", ch.Purpose)
- }
- if ch.Topic != "" {
- _, _ = common.Dim.Printf(" Topic: %s\n", ch.Topic)
- }
-
- return nil
+ return withSlackClient(func(ctx context.Context, client ports.SlackClient) error {
+ channelID := args[0]
+ ch, err := client.GetChannel(ctx, channelID)
+ if err != nil {
+ return common.WrapGetError("channel", err)
+ }
+
+ // Handle structured output (JSON/YAML/quiet)
+ if common.IsStructuredOutput(cmd) {
+ out := common.GetOutputWriter(cmd)
+ return out.Write(ch)
+ }
+
+ _, _ = common.Cyan.Printf("Channel: #%s\n", ch.Name)
+ fmt.Printf(" ID: %s\n", ch.ID)
+ fmt.Printf(" Is Channel: %v\n", ch.IsChannel)
+ fmt.Printf(" Is Private: %v\n", ch.IsPrivate)
+ fmt.Printf(" Is Archived: %v\n", ch.IsArchived)
+ fmt.Printf(" Is Member: %v\n", ch.IsMember)
+ fmt.Printf(" Is Shared: %v\n", ch.IsShared)
+ fmt.Printf(" Is OrgShared: %v\n", ch.IsOrgShared)
+ fmt.Printf(" Is ExtShared: %v\n", ch.IsExtShared)
+ fmt.Printf(" Is IM: %v\n", ch.IsIM)
+ fmt.Printf(" Is MPIM: %v\n", ch.IsMPIM)
+ fmt.Printf(" Is Group: %v\n", ch.IsGroup)
+ fmt.Printf(" Members: %d\n", ch.MemberCount)
+ if ch.Purpose != "" {
+ _, _ = common.Dim.Printf(" Purpose: %s\n", ch.Purpose)
+ }
+ if ch.Topic != "" {
+ _, _ = common.Dim.Printf(" Topic: %s\n", ch.Topic)
+ }
+
+ return nil
+ })
},
}
diff --git a/internal/cli/slack/channels.go b/internal/cli/slack/channels.go
index ed88088..85d15bd 100644
--- a/internal/cli/slack/channels.go
+++ b/internal/cli/slack/channels.go
@@ -69,12 +69,9 @@ Examples:
# Exclude archived channels
nylas slack channels list --exclude-archived`,
RunE: func(cmd *cobra.Command, args []string) error {
- client, err := getSlackClientFromKeyring()
+ client, err := getSlackClientOrError()
if err != nil {
- return common.NewUserError(
- "not authenticated with Slack",
- "Run: nylas slack auth set --token YOUR_TOKEN",
- )
+ return err
}
// Parse created-after duration if provided
@@ -163,9 +160,7 @@ Examples:
}
// Handle structured output (JSON/YAML/quiet)
- format, _ := cmd.Flags().GetString("format")
- quiet, _ := cmd.Flags().GetBool("quiet")
- if common.IsJSON(cmd) || format == "yaml" || quiet {
+ if common.IsStructuredOutput(cmd) {
out := common.GetOutputWriter(cmd)
return out.Write(allChannels)
}
diff --git a/internal/cli/slack/files.go b/internal/cli/slack/files.go
index 2b074dd..2bb5f00 100644
--- a/internal/cli/slack/files.go
+++ b/internal/cli/slack/files.go
@@ -77,12 +77,9 @@ Examples:
# List files uploaded by a specific user
nylas slack files list --user U1234567890`,
RunE: func(cmd *cobra.Command, args []string) error {
- client, err := getSlackClientFromKeyring()
+ client, err := getSlackClientOrError()
if err != nil {
- return common.NewUserError(
- "not authenticated with Slack",
- "Run: nylas slack auth set --token YOUR_TOKEN",
- )
+ return err
}
ctx, cancel := common.CreateContext()
@@ -124,9 +121,7 @@ Examples:
}
// Handle structured output (JSON/YAML/quiet)
- format, _ := cmd.Flags().GetString("format")
- quiet, _ := cmd.Flags().GetBool("quiet")
- if common.IsJSON(cmd) || format == "yaml" || quiet {
+ if common.IsStructuredOutput(cmd) {
out := common.GetOutputWriter(cmd)
return out.Write(resp.Files)
}
@@ -193,12 +188,9 @@ func newFilesShowCmd() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
fileID := args[0]
- client, err := getSlackClientFromKeyring()
+ client, err := getSlackClientOrError()
if err != nil {
- return common.NewUserError(
- "not authenticated with Slack",
- "Run: nylas slack auth set --token YOUR_TOKEN",
- )
+ return err
}
ctx, cancel := common.CreateContext()
@@ -210,9 +202,7 @@ func newFilesShowCmd() *cobra.Command {
}
// Handle structured output (JSON/YAML/quiet)
- format, _ := cmd.Flags().GetString("format")
- quiet, _ := cmd.Flags().GetBool("quiet")
- if common.IsJSON(cmd) || format == "yaml" || quiet {
+ if common.IsStructuredOutput(cmd) {
out := common.GetOutputWriter(cmd)
return out.Write(file)
}
@@ -278,12 +268,9 @@ Examples:
RunE: func(cmd *cobra.Command, args []string) error {
fileID := args[0]
- client, err := getSlackClientFromKeyring()
+ client, err := getSlackClientOrError()
if err != nil {
- return common.NewUserError(
- "not authenticated with Slack",
- "Run: nylas slack auth set --token YOUR_TOKEN",
- )
+ return err
}
ctx, cancel := common.CreateContext()
diff --git a/internal/cli/slack/helpers.go b/internal/cli/slack/helpers.go
index 202180e..cf4b313 100644
--- a/internal/cli/slack/helpers.go
+++ b/internal/cli/slack/helpers.go
@@ -11,6 +11,7 @@ import (
"github.com/nylas/cli/internal/adapters/config"
"github.com/nylas/cli/internal/adapters/keyring"
slackadapter "github.com/nylas/cli/internal/adapters/slack"
+ "github.com/nylas/cli/internal/cli/common"
"github.com/nylas/cli/internal/domain"
"github.com/nylas/cli/internal/ports"
)
@@ -71,6 +72,29 @@ func getSlackClient(token string) (ports.SlackClient, error) {
return slackadapter.NewClient(config)
}
+// withSlackClient creates a Slack client and context, then runs fn.
+func withSlackClient(fn func(ctx context.Context, client ports.SlackClient) error) error {
+ client, err := getSlackClientOrError()
+ if err != nil {
+ return err
+ }
+ ctx, cancel := common.CreateContext()
+ defer cancel()
+ return fn(ctx, client)
+}
+
+// getSlackClientOrError wraps getSlackClientFromKeyring with a user-friendly error.
+func getSlackClientOrError() (ports.SlackClient, error) {
+ client, err := getSlackClientFromKeyring()
+ if err != nil {
+ return nil, common.NewUserError(
+ "not authenticated with Slack",
+ "Run: nylas slack auth set --token YOUR_TOKEN",
+ )
+ }
+ return client, nil
+}
+
// createContext creates a context with default timeout.
// Uses common.CreateContext for consistency across CLI packages.
diff --git a/internal/cli/slack/messages.go b/internal/cli/slack/messages.go
index bdfdf87..ffcd58b 100644
--- a/internal/cli/slack/messages.go
+++ b/internal/cli/slack/messages.go
@@ -66,12 +66,9 @@ Examples:
# Expand all threads inline (show thread replies under parent messages)
nylas slack messages list --channel general --expand-threads`,
RunE: func(cmd *cobra.Command, args []string) error {
- client, err := getSlackClientFromKeyring()
+ client, err := getSlackClientOrError()
if err != nil {
- return common.NewUserError(
- "not authenticated with Slack",
- "Run: nylas slack auth set --token YOUR_TOKEN",
- )
+ return err
}
// Use longer timeout when fetching all messages
@@ -144,9 +141,7 @@ Examples:
}
// Handle structured output (JSON/YAML/quiet) - before enrichment for performance
- format, _ := cmd.Flags().GetString("format")
- quiet, _ := cmd.Flags().GetBool("quiet")
- if common.IsJSON(cmd) || format == "yaml" || quiet {
+ if common.IsStructuredOutput(cmd) {
out := common.GetOutputWriter(cmd)
return out.Write(allMessages)
}
diff --git a/internal/cli/slack/search.go b/internal/cli/slack/search.go
index 10111f2..cba9e0e 100644
--- a/internal/cli/slack/search.go
+++ b/internal/cli/slack/search.go
@@ -43,12 +43,9 @@ Examples:
return common.NewUserError("search query is required", "Use --query")
}
- client, err := getSlackClientFromKeyring()
+ client, err := getSlackClientOrError()
if err != nil {
- return common.NewUserError(
- "not authenticated with Slack",
- "Run: nylas slack auth set --token YOUR_TOKEN",
- )
+ return err
}
ctx, cancel := common.CreateContext()
@@ -65,9 +62,7 @@ Examples:
}
// Handle structured output (JSON/YAML/quiet)
- format, _ := cmd.Flags().GetString("format")
- quiet, _ := cmd.Flags().GetBool("quiet")
- if common.IsJSON(cmd) || format == "yaml" || quiet {
+ if common.IsStructuredOutput(cmd) {
out := common.GetOutputWriter(cmd)
return out.Write(messages)
}
diff --git a/internal/cli/slack/send.go b/internal/cli/slack/send.go
index 9594602..011e8ce 100644
--- a/internal/cli/slack/send.go
+++ b/internal/cli/slack/send.go
@@ -38,12 +38,9 @@ Examples:
# Send without confirmation
nylas slack send --channel general --text "Quick update" --yes`,
RunE: func(cmd *cobra.Command, args []string) error {
- client, err := getSlackClientFromKeyring()
+ client, err := getSlackClientOrError()
if err != nil {
- return common.NewUserError(
- "not authenticated with Slack",
- "Run: nylas slack auth set --token YOUR_TOKEN",
- )
+ return err
}
ctx, cancel := common.CreateContext()
@@ -126,12 +123,9 @@ Examples:
# Reply and also post to channel
nylas slack reply --channel general --thread 1234567890.123456 --text "Update" --broadcast`,
RunE: func(cmd *cobra.Command, args []string) error {
- client, err := getSlackClientFromKeyring()
+ client, err := getSlackClientOrError()
if err != nil {
- return common.NewUserError(
- "not authenticated with Slack",
- "Run: nylas slack auth set --token YOUR_TOKEN",
- )
+ return err
}
ctx, cancel := common.CreateContext()
diff --git a/internal/cli/slack/users.go b/internal/cli/slack/users.go
index 7154613..d6d2bf5 100644
--- a/internal/cli/slack/users.go
+++ b/internal/cli/slack/users.go
@@ -51,12 +51,9 @@ Examples:
# Limit results
nylas slack users list --limit 20`,
RunE: func(cmd *cobra.Command, args []string) error {
- client, err := getSlackClientFromKeyring()
+ client, err := getSlackClientOrError()
if err != nil {
- return common.NewUserError(
- "not authenticated with Slack",
- "Run: nylas slack auth set --token YOUR_TOKEN",
- )
+ return err
}
ctx, cancel := common.CreateContext()
@@ -73,9 +70,7 @@ Examples:
}
// Handle structured output (JSON/YAML/quiet)
- format, _ := cmd.Flags().GetString("format")
- quiet, _ := cmd.Flags().GetBool("quiet")
- if common.IsJSON(cmd) || format == "yaml" || quiet {
+ if common.IsStructuredOutput(cmd) {
out := common.GetOutputWriter(cmd)
return out.Write(resp.Users)
}
@@ -135,12 +130,9 @@ Examples:
nylas slack users get @username`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
- client, err := getSlackClientFromKeyring()
+ client, err := getSlackClientOrError()
if err != nil {
- return common.NewUserError(
- "not authenticated with Slack",
- "Run: nylas slack auth set --token YOUR_TOKEN",
- )
+ return err
}
ctx, cancel := common.CreateContext()
@@ -166,9 +158,7 @@ Examples:
}
// Handle structured output (JSON/YAML/quiet)
- format, _ := cmd.Flags().GetString("format")
- quiet, _ := cmd.Flags().GetBool("quiet")
- if common.IsJSON(cmd) || format == "yaml" || quiet {
+ if common.IsStructuredOutput(cmd) {
out := common.GetOutputWriter(cmd)
return out.Write(user)
}
From bcb11c9778d8ae24b19e4673c21b02d32f946559 Mon Sep 17 00:00:00 2001
From: Qasim
Date: Fri, 13 Feb 2026 08:10:07 -0500
Subject: [PATCH 6/9] refactor(cli): replace local duplicates with common
helpers
- Replace local truncate/truncateString with common.Truncate() (audit, slack, integration tests)
- Replace local printJSON with common.PrintJSON() (timezone package, 5 files)
- Widen IsJSON(cmd) to IsStructuredOutput(cmd) so --format yaml and --quiet
use the structured output path instead of falling through to table (15 files)
- Fix buggy truncate in email_gpg_test.go that produced maxLen+3 chars
---
internal/cli/audit/logs_show.go | 13 +++----------
internal/cli/auth/list.go | 2 +-
internal/cli/calendar/events_list.go | 2 +-
internal/cli/calendar/list.go | 6 +++---
internal/cli/contacts/groups.go | 2 +-
internal/cli/contacts/list.go | 2 +-
internal/cli/email/attachments.go | 2 +-
internal/cli/email/drafts.go | 2 +-
internal/cli/email/folders.go | 2 +-
internal/cli/email/list.go | 2 +-
internal/cli/email/scheduled.go | 2 +-
internal/cli/email/templates_create.go | 2 +-
internal/cli/email/templates_list.go | 2 +-
internal/cli/email/templates_show.go | 2 +-
internal/cli/email/templates_update.go | 2 +-
internal/cli/email/threads.go | 2 +-
internal/cli/integration/email_gpg_test.go | 17 ++++++-----------
internal/cli/slack/channels.go | 10 +---------
internal/cli/timezone/convert.go | 2 +-
internal/cli/timezone/dst.go | 2 +-
internal/cli/timezone/find.go | 2 +-
internal/cli/timezone/helpers.go | 9 ---------
internal/cli/timezone/info.go | 2 +-
internal/cli/timezone/list.go | 2 +-
24 files changed, 32 insertions(+), 61 deletions(-)
diff --git a/internal/cli/audit/logs_show.go b/internal/cli/audit/logs_show.go
index f08244f..ac53423 100644
--- a/internal/cli/audit/logs_show.go
+++ b/internal/cli/audit/logs_show.go
@@ -169,9 +169,9 @@ func showEntryTable(cmd *cobra.Command, entries []domain.AuditEntry) error {
Duration: FormatDuration(e.Duration),
RequestID: e.RequestID,
HTTPStatus: e.HTTPStatus,
- GrantDisplay: truncate(orDash(e.GrantID), 12),
- InvokerDisplay: truncate(orDash(e.Invoker), 12),
- SourceDisplay: truncate(orDash(e.InvokerSource), 12),
+ GrantDisplay: common.Truncate(orDash(e.GrantID), 12),
+ InvokerDisplay: common.Truncate(orDash(e.Invoker), 12),
+ SourceDisplay: common.Truncate(orDash(e.InvokerSource), 12),
}
}
@@ -246,13 +246,6 @@ func parseDate(s string) (time.Time, error) {
return time.Time{}, fmt.Errorf("unrecognized date format: %s", s)
}
-func truncate(s string, max int) string {
- if len(s) <= max {
- return s
- }
- return s[:max-3] + "..."
-}
-
// orDash returns "-" if s is empty, otherwise returns s.
func orDash(s string) string {
if s == "" {
diff --git a/internal/cli/auth/list.go b/internal/cli/auth/list.go
index cf792cf..d4c0d98 100644
--- a/internal/cli/auth/list.go
+++ b/internal/cli/auth/list.go
@@ -32,7 +32,7 @@ func newListCmd() *cobra.Command {
}
// Check if we should use structured output
- if common.IsJSON(cmd) {
+ if common.IsStructuredOutput(cmd) {
out := common.GetOutputWriter(cmd)
return out.Write(grants)
}
diff --git a/internal/cli/calendar/events_list.go b/internal/cli/calendar/events_list.go
index 8538571..b235358 100644
--- a/internal/cli/calendar/events_list.go
+++ b/internal/cli/calendar/events_list.go
@@ -119,7 +119,7 @@ Examples:
}
// JSON output (including empty array)
- if common.IsJSON(cmd) {
+ if common.IsStructuredOutput(cmd) {
out := common.GetOutputWriter(cmd)
return struct{}{}, out.Write(events)
}
diff --git a/internal/cli/calendar/list.go b/internal/cli/calendar/list.go
index 15b1d2a..7e8162e 100644
--- a/internal/cli/calendar/list.go
+++ b/internal/cli/calendar/list.go
@@ -27,14 +27,14 @@ func newListCmd() *cobra.Command {
}
if len(calendars) == 0 {
- if !common.IsQuiet() && !common.IsJSON(cmd) {
+ if !common.IsStructuredOutput(cmd) {
common.PrintEmptyState("calendars")
}
return nil
}
- // Check if using structured output (JSON/YAML)
- if common.IsJSON(cmd) {
+ // Check if using structured output (JSON/YAML/quiet)
+ if common.IsStructuredOutput(cmd) {
out := common.GetOutputWriter(cmd)
return out.Write(calendars)
}
diff --git a/internal/cli/contacts/groups.go b/internal/cli/contacts/groups.go
index 1b21616..5c9ed44 100644
--- a/internal/cli/contacts/groups.go
+++ b/internal/cli/contacts/groups.go
@@ -42,7 +42,7 @@ func newGroupsListCmd() *cobra.Command {
}
// JSON output (including empty array)
- if common.IsJSON(cmd) {
+ if common.IsStructuredOutput(cmd) {
out := common.GetOutputWriter(cmd)
return struct{}{}, out.Write(groups)
}
diff --git a/internal/cli/contacts/list.go b/internal/cli/contacts/list.go
index 86d0727..ab36cde 100644
--- a/internal/cli/contacts/list.go
+++ b/internal/cli/contacts/list.go
@@ -33,7 +33,7 @@ func newListCmd() *cobra.Command {
}
// Check if we should use structured output (JSON/YAML/quiet)
- if common.IsJSON(cmd) {
+ if common.IsStructuredOutput(cmd) {
_, err := common.WithClient(args, func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) {
params := &domain.ContactQueryParams{
Limit: limit,
diff --git a/internal/cli/email/attachments.go b/internal/cli/email/attachments.go
index 616bab6..08528ae 100644
--- a/internal/cli/email/attachments.go
+++ b/internal/cli/email/attachments.go
@@ -46,7 +46,7 @@ func newAttachmentsListCmd() *cobra.Command {
}
// JSON output (including empty array)
- if common.IsJSON(cmd) {
+ if common.IsStructuredOutput(cmd) {
out := common.GetOutputWriter(cmd)
return struct{}{}, out.Write(attachments)
}
diff --git a/internal/cli/email/drafts.go b/internal/cli/email/drafts.go
index b9af5e6..911907d 100644
--- a/internal/cli/email/drafts.go
+++ b/internal/cli/email/drafts.go
@@ -47,7 +47,7 @@ func newDraftsListCmd() *cobra.Command {
}
// JSON output (including empty array)
- if common.IsJSON(cmd) {
+ if common.IsStructuredOutput(cmd) {
out := common.GetOutputWriter(cmd)
return struct{}{}, out.Write(drafts)
}
diff --git a/internal/cli/email/folders.go b/internal/cli/email/folders.go
index 266e8b1..131b925 100644
--- a/internal/cli/email/folders.go
+++ b/internal/cli/email/folders.go
@@ -41,7 +41,7 @@ func newFoldersListCmd() *cobra.Command {
}
// JSON output (including empty array)
- if common.IsJSON(cmd) {
+ if common.IsStructuredOutput(cmd) {
out := common.GetOutputWriter(cmd)
return struct{}{}, out.Write(folders)
}
diff --git a/internal/cli/email/list.go b/internal/cli/email/list.go
index cad1d4a..37b0327 100644
--- a/internal/cli/email/list.go
+++ b/internal/cli/email/list.go
@@ -51,7 +51,7 @@ Use --max to limit total messages when using --all.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// Check if we should use structured output (JSON/YAML/quiet)
- if common.IsJSON(cmd) {
+ if common.IsStructuredOutput(cmd) {
return runListStructured(cmd, args, limit, unread, starred, from, folder, allFolders, all, maxItems, metadataPair)
}
diff --git a/internal/cli/email/scheduled.go b/internal/cli/email/scheduled.go
index 65a9158..545b1ed 100644
--- a/internal/cli/email/scheduled.go
+++ b/internal/cli/email/scheduled.go
@@ -38,7 +38,7 @@ func newScheduledListCmd() *cobra.Command {
}
// JSON output (including empty array)
- if common.IsJSON(cmd) {
+ if common.IsStructuredOutput(cmd) {
out := common.GetOutputWriter(cmd)
return struct{}{}, out.Write(scheduled)
}
diff --git a/internal/cli/email/templates_create.go b/internal/cli/email/templates_create.go
index ced9ef8..9fd21b1 100644
--- a/internal/cli/email/templates_create.go
+++ b/internal/cli/email/templates_create.go
@@ -111,7 +111,7 @@ Use --interactive for a guided creation experience.`,
}
// JSON output
- if common.IsJSON(cmd) {
+ if common.IsStructuredOutput(cmd) {
return common.PrintJSON(created)
}
diff --git a/internal/cli/email/templates_list.go b/internal/cli/email/templates_list.go
index b3eac89..5e06c79 100644
--- a/internal/cli/email/templates_list.go
+++ b/internal/cli/email/templates_list.go
@@ -36,7 +36,7 @@ Use --json or --yaml for machine-readable output.`,
}
// JSON output
- if common.IsJSON(cmd) {
+ if common.IsStructuredOutput(cmd) {
return common.PrintJSON(templates)
}
diff --git a/internal/cli/email/templates_show.go b/internal/cli/email/templates_show.go
index 1cb8b72..00d8384 100644
--- a/internal/cli/email/templates_show.go
+++ b/internal/cli/email/templates_show.go
@@ -41,7 +41,7 @@ Shows the template's name, subject, body, variables, and usage statistics.`,
}
// JSON output
- if common.IsJSON(cmd) {
+ if common.IsStructuredOutput(cmd) {
return common.PrintJSON(tpl)
}
diff --git a/internal/cli/email/templates_update.go b/internal/cli/email/templates_update.go
index 9a4c96a..8f471ad 100644
--- a/internal/cli/email/templates_update.go
+++ b/internal/cli/email/templates_update.go
@@ -81,7 +81,7 @@ re-extracted from the updated subject and body.`,
}
// JSON output
- if common.IsJSON(cmd) {
+ if common.IsStructuredOutput(cmd) {
return common.PrintJSON(updated)
}
diff --git a/internal/cli/email/threads.go b/internal/cli/email/threads.go
index 80a1600..2574919 100644
--- a/internal/cli/email/threads.go
+++ b/internal/cli/email/threads.go
@@ -59,7 +59,7 @@ func newThreadsListCmd() *cobra.Command {
}
// JSON output (including empty array)
- if common.IsJSON(cmd) {
+ if common.IsStructuredOutput(cmd) {
out := common.GetOutputWriter(cmd)
return struct{}{}, out.Write(threads)
}
diff --git a/internal/cli/integration/email_gpg_test.go b/internal/cli/integration/email_gpg_test.go
index 378a08a..31a323e 100644
--- a/internal/cli/integration/email_gpg_test.go
+++ b/internal/cli/integration/email_gpg_test.go
@@ -7,6 +7,8 @@ import (
"strings"
"testing"
"time"
+
+ "github.com/nylas/cli/internal/cli/common"
)
// =============================================================================
@@ -224,7 +226,7 @@ func TestCLI_EmailRead_RawMIME(t *testing.T) {
t.Errorf("Expected MIME headers in output, got: %s", mimeStdout)
}
- t.Logf("Raw MIME output (first 500 chars):\n%s", truncate(mimeStdout, 500))
+ t.Logf("Raw MIME output (first 500 chars):\n%s", common.Truncate(mimeStdout, 500))
// Cleanup
acquireRateLimit(t)
@@ -275,17 +277,17 @@ func TestCLI_EmailRead_SignedMIME(t *testing.T) {
// Should be multipart/signed
if !strings.Contains(mimeStdout, "multipart/signed") {
- t.Errorf("Expected 'multipart/signed' in MIME output, got: %s", truncate(mimeStdout, 500))
+ t.Errorf("Expected 'multipart/signed' in MIME output, got: %s", common.Truncate(mimeStdout, 500))
}
// Should contain PGP signature
if !strings.Contains(mimeStdout, "application/pgp-signature") {
- t.Errorf("Expected 'application/pgp-signature' in MIME output, got: %s", truncate(mimeStdout, 500))
+ t.Errorf("Expected 'application/pgp-signature' in MIME output, got: %s", common.Truncate(mimeStdout, 500))
}
// Should contain BEGIN PGP SIGNATURE
if !strings.Contains(mimeStdout, "BEGIN PGP SIGNATURE") {
- t.Errorf("Expected 'BEGIN PGP SIGNATURE' in MIME output, got: %s", truncate(mimeStdout, 500))
+ t.Errorf("Expected 'BEGIN PGP SIGNATURE' in MIME output, got: %s", common.Truncate(mimeStdout, 500))
}
t.Logf("Signed MIME structure verified")
@@ -354,10 +356,3 @@ func extractMessageID(output string) string {
return ""
}
-// truncate truncates a string to maxLen characters
-func truncate(s string, maxLen int) string {
- if len(s) <= maxLen {
- return s
- }
- return s[:maxLen] + "..."
-}
diff --git a/internal/cli/slack/channels.go b/internal/cli/slack/channels.go
index 85d15bd..dba7897 100644
--- a/internal/cli/slack/channels.go
+++ b/internal/cli/slack/channels.go
@@ -218,15 +218,7 @@ func printChannels(channels []domain.SlackChannel, showID bool) {
fmt.Println()
if ch.Purpose != "" {
- _, _ = dim.Printf(" %s\n", truncateString(ch.Purpose, 60))
+ _, _ = dim.Printf(" %s\n", common.Truncate(ch.Purpose, 60))
}
}
}
-
-// truncateString shortens a string to maxLen, adding "..." if truncated.
-func truncateString(s string, maxLen int) string {
- if len(s) <= maxLen {
- return s
- }
- return s[:maxLen-3] + "..."
-}
diff --git a/internal/cli/timezone/convert.go b/internal/cli/timezone/convert.go
index 6a7dc4b..5a2c08b 100644
--- a/internal/cli/timezone/convert.go
+++ b/internal/cli/timezone/convert.go
@@ -93,7 +93,7 @@ func runConvert(fromZone, toZone, timeStr string, jsonOut bool) error {
// Output
if jsonOut {
- return printJSON(map[string]any{
+ return common.PrintJSON(map[string]any{
"from": map[string]any{
"zone": fromZone,
"time": inputTime.Format(time.RFC3339),
diff --git a/internal/cli/timezone/dst.go b/internal/cli/timezone/dst.go
index 73ca077..f10fe82 100644
--- a/internal/cli/timezone/dst.go
+++ b/internal/cli/timezone/dst.go
@@ -69,7 +69,7 @@ func runDST(zone string, year int, jsonOut bool) error {
// Output
if jsonOut {
- return printJSON(map[string]any{
+ return common.PrintJSON(map[string]any{
"zone": zone,
"year": year,
"transitions": transitions,
diff --git a/internal/cli/timezone/find.go b/internal/cli/timezone/find.go
index a16714b..2e2dc9b 100644
--- a/internal/cli/timezone/find.go
+++ b/internal/cli/timezone/find.go
@@ -144,7 +144,7 @@ func runFindMeeting(zonesStr, durationStr, startHour, endHour,
// Output
if jsonOut {
- return printJSON(result)
+ return common.PrintJSON(result)
}
// Human-readable output
diff --git a/internal/cli/timezone/helpers.go b/internal/cli/timezone/helpers.go
index 5ac7d07..a4c2ae1 100644
--- a/internal/cli/timezone/helpers.go
+++ b/internal/cli/timezone/helpers.go
@@ -1,9 +1,7 @@
package timezone
import (
- "encoding/json"
"fmt"
- "os"
"strings"
"time"
@@ -24,13 +22,6 @@ func formatTime(t time.Time, showZone bool) string {
return t.Format("2006-01-02 15:04:05")
}
-// printJSON prints data as formatted JSON.
-func printJSON(data any) error {
- encoder := json.NewEncoder(os.Stdout)
- encoder.SetIndent("", " ")
- return encoder.Encode(data)
-}
-
// parseTimeZones parses a comma-separated list of time zones.
func parseTimeZones(zonesStr string) []string {
if zonesStr == "" {
diff --git a/internal/cli/timezone/info.go b/internal/cli/timezone/info.go
index 6a812b2..9969139 100644
--- a/internal/cli/timezone/info.go
+++ b/internal/cli/timezone/info.go
@@ -96,7 +96,7 @@ func runInfo(zone, timeStr string, jsonOut bool) error {
// Output
if jsonOut {
- return printJSON(map[string]any{
+ return common.PrintJSON(map[string]any{
"zone": info.Name,
"abbreviation": info.Abbreviation,
"offset": formatOffset(info.Offset),
diff --git a/internal/cli/timezone/list.go b/internal/cli/timezone/list.go
index 689e891..bfc0947 100644
--- a/internal/cli/timezone/list.go
+++ b/internal/cli/timezone/list.go
@@ -72,7 +72,7 @@ func runList(filter string, jsonOut bool) error {
// Output
if jsonOut {
- return printJSON(map[string]any{
+ return common.PrintJSON(map[string]any{
"zones": zones,
"count": len(zones),
})
From 4ab622e29a7f336655144a3647950d6c57f204b2 Mon Sep 17 00:00:00 2001
From: Qasim
Date: Fri, 13 Feb 2026 10:49:03 -0500
Subject: [PATCH 7/9] fix(inbound): accept full email addresses and sync grants
locally
The create command rejected '@' and '*' characters, preventing full
email addresses and wildcard patterns. Also, created inboxes were not
saved to the local grant store, so they didn't appear in `auth list`.
---
internal/cli/inbound/create.go | 41 +++++++++++++++-------------
internal/cli/inbound/delete.go | 3 ++
internal/cli/inbound/helpers.go | 26 ++++++++++++++++++
internal/cli/inbound/inbound_test.go | 7 ++---
4 files changed, 54 insertions(+), 23 deletions(-)
diff --git a/internal/cli/inbound/create.go b/internal/cli/inbound/create.go
index edd1cae..581e36a 100644
--- a/internal/cli/inbound/create.go
+++ b/internal/cli/inbound/create.go
@@ -15,21 +15,22 @@ func newCreateCmd() *cobra.Command {
var jsonOutput bool
cmd := &cobra.Command{
- Use: "create ",
+ Use: "create ",
Short: "Create a new inbound inbox",
Long: `Create a new inbound inbox with a managed email address.
-The email prefix you provide will be combined with your application's
-Nylas domain to create the full email address (e.g., support@yourapp.nylas.email).
+You can provide either a full email address or just the local part (prefix).
+Wildcards (*) are supported for catch-all patterns.
Examples:
- # Create a support inbox
+ # Create with full email address
+ nylas inbound create support@yourapp.nylas.email
+
+ # Create with just the prefix (domain added by API)
nylas inbound create support
- # Creates: support@yourapp.nylas.email
- # Create a leads inbox
- nylas inbound create leads
- # Creates: leads@yourapp.nylas.email
+ # Create a wildcard catch-all inbox
+ nylas inbound create "e2e-*@yourapp.nylas.email"
# Create and output as JSON
nylas inbound create tickets --json`,
@@ -44,26 +45,27 @@ Examples:
return cmd
}
-func runCreate(emailPrefix string, jsonOutput bool) error {
- // Validate email prefix
- emailPrefix = strings.TrimSpace(emailPrefix)
- if emailPrefix == "" {
- printError("Email prefix cannot be empty")
- return common.NewInputError("email prefix cannot be empty")
+func runCreate(email string, jsonOutput bool) error {
+ email = strings.TrimSpace(email)
+ if email == "" {
+ printError("Email address cannot be empty")
+ return common.NewInputError("email address cannot be empty")
}
- // Basic validation - no @ symbol, no spaces
- if strings.Contains(emailPrefix, "@") || strings.Contains(emailPrefix, " ") {
- printError("Email prefix should not contain '@' or spaces. Just provide the local part (e.g., 'support')")
- return common.NewInputError("invalid email prefix - should not contain '@' or spaces")
+ if strings.Contains(email, " ") {
+ printError("Email address should not contain spaces")
+ return common.NewInputError("invalid email address - should not contain spaces")
}
_, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) {
- inbox, err := client.CreateInboundInbox(ctx, emailPrefix)
+ inbox, err := client.CreateInboundInbox(ctx, email)
if err != nil {
return struct{}{}, common.WrapCreateError("inbound inbox", err)
}
+ // Save the new grant to local store so it appears in `nylas auth list`
+ saveGrantLocally(inbox.ID, inbox.Email)
+
if jsonOutput {
data, _ := json.MarshalIndent(inbox, "", " ")
fmt.Println(string(data))
@@ -85,3 +87,4 @@ func runCreate(emailPrefix string, jsonOutput bool) error {
return err
}
+
diff --git a/internal/cli/inbound/delete.go b/internal/cli/inbound/delete.go
index 6346315..74ac0b8 100644
--- a/internal/cli/inbound/delete.go
+++ b/internal/cli/inbound/delete.go
@@ -89,6 +89,9 @@ Examples:
return common.WrapDeleteError("inbox", err)
}
+ // Remove from local grant store
+ removeGrantLocally(inboxID)
+
printSuccess("Inbox %s deleted successfully!", inbox.Email)
return nil
diff --git a/internal/cli/inbound/helpers.go b/internal/cli/inbound/helpers.go
index a63d029..b4d5a9b 100644
--- a/internal/cli/inbound/helpers.go
+++ b/internal/cli/inbound/helpers.go
@@ -5,6 +5,8 @@ import (
"os"
"strings"
+ "github.com/nylas/cli/internal/adapters/config"
+ "github.com/nylas/cli/internal/adapters/keyring"
"github.com/nylas/cli/internal/cli/common"
"github.com/nylas/cli/internal/domain"
)
@@ -80,6 +82,30 @@ func formatStatus(status string) string {
}
}
+// saveGrantLocally saves the inbound inbox grant to the local keyring store.
+func saveGrantLocally(grantID, email string) {
+ secretStore, err := keyring.NewSecretStore(config.DefaultConfigDir())
+ if err != nil {
+ return
+ }
+ grantStore := keyring.NewGrantStore(secretStore)
+ _ = grantStore.SaveGrant(domain.GrantInfo{
+ ID: grantID,
+ Email: email,
+ Provider: domain.ProviderInbox,
+ })
+}
+
+// removeGrantLocally removes the inbound inbox grant from the local keyring store.
+func removeGrantLocally(grantID string) {
+ secretStore, err := keyring.NewSecretStore(config.DefaultConfigDir())
+ if err != nil {
+ return
+ }
+ grantStore := keyring.NewGrantStore(secretStore)
+ _ = grantStore.DeleteGrant(grantID)
+}
+
// printInboundMessageSummary prints an inbound message summary.
func printInboundMessageSummary(msg domain.InboundMessage, _ int) {
status := " "
diff --git a/internal/cli/inbound/inbound_test.go b/internal/cli/inbound/inbound_test.go
index 4fbc4fd..465de22 100644
--- a/internal/cli/inbound/inbound_test.go
+++ b/internal/cli/inbound/inbound_test.go
@@ -131,7 +131,7 @@ func TestCreateCommand(t *testing.T) {
cmd := newCreateCmd()
t.Run("command_name", func(t *testing.T) {
- assert.Equal(t, "create ", cmd.Use)
+ assert.Equal(t, "create ", cmd.Use)
})
t.Run("has_short_description", func(t *testing.T) {
@@ -146,8 +146,7 @@ func TestCreateCommand(t *testing.T) {
})
t.Run("requires_one_argument", func(t *testing.T) {
- // Test that the command requires exactly one argument by checking the help text
- assert.Contains(t, cmd.Use, "")
+ assert.Contains(t, cmd.Use, "")
})
t.Run("has_long_description_with_examples", func(t *testing.T) {
@@ -465,7 +464,7 @@ func TestInboundCreateHelp(t *testing.T) {
assert.NoError(t, err)
assert.Contains(t, stdout, "create")
assert.Contains(t, stdout, "--json")
- assert.Contains(t, stdout, "email-prefix")
+ assert.Contains(t, stdout, "email")
}
func TestInboundDeleteHelp(t *testing.T) {
From da58820df6eaf4dd47df51800af4eed7d190451e Mon Sep 17 00:00:00 2001
From: Qasim
Date: Fri, 13 Feb 2026 10:59:59 -0500
Subject: [PATCH 8/9] fix(chat): return empty slice instead of nil from
DetectAgents
When no agents are installed (e.g., CI runners), DetectAgents returned
nil which caused TestDetectAgents_Structure to fail with NotNil assert.
---
internal/chat/agent.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/internal/chat/agent.go b/internal/chat/agent.go
index 59bbaad..1638196 100644
--- a/internal/chat/agent.go
+++ b/internal/chat/agent.go
@@ -30,7 +30,7 @@ type Agent struct {
// DetectAgents scans the system for installed AI agents.
// It checks for claude, codex, and ollama in $PATH.
func DetectAgents() []Agent {
- var agents []Agent
+ agents := []Agent{}
checks := []struct {
name AgentType
From 134a5297f3d9b099f912ed553f246358eb7a745e Mon Sep 17 00:00:00 2001
From: Qasim
Date: Fri, 13 Feb 2026 11:00:29 -0500
Subject: [PATCH 9/9] fix inbound
---
internal/cli/inbound/create.go | 1 -
1 file changed, 1 deletion(-)
diff --git a/internal/cli/inbound/create.go b/internal/cli/inbound/create.go
index 581e36a..fbd4afe 100644
--- a/internal/cli/inbound/create.go
+++ b/internal/cli/inbound/create.go
@@ -87,4 +87,3 @@ func runCreate(email string, jsonOutput bool) error {
return err
}
-