5 Commits

Author SHA1 Message Date
loveuer c8d8329dbb feat(tui): codex-style input, history, bang commands, single-line status bar
- Arrow-key input history (up/down with multiline awareness)
- Bang shell commands via ! prefix (requires --yolo)
- Codex-style input bar with mode indicator (R/Y/⌘)
- Command palette virtual scrolling
- Multiline input viewport fix
- Merged model pills + footer into single status line
- Removed shift+enter (wait for TUI library support)
- Removed default footer hints
2026-06-25 20:40:50 -07:00
loveuer dabf5bfecc compact: retain newest ~20% of context; refactor startup into internal/app
- Replace fixed 6-message compact retention with ratio-based logic:
  retain ~20% of compactable estimated tokens, with a 6-message floor
  and user-turn boundary alignment
- Add compactRetainedTokenBudget, compactRecentStart, compactTurnBoundary
  helpers in internal/agent/agent.go
- Add TestAgentCompactKeepsTwentyPercentRecentContext
- Move startup/session/repl/bootstrap logic from root into internal/app
  so main.go is a thin binary entry point (15 lines)
- Update /compact docs in README.md
2026-06-24 23:59:21 -07:00
loveuer 9abd5c3b5f feat(tui): implement dark mode with runtime /theme switching
- Paint dark background at app shell level via appStyle() helper
- Add /theme slash command for runtime light/dark switching
- Add comprehensive dark-mode style tests
- Document --theme dark in README Quick Start
2026-06-24 20:45:06 -07:00
loveuer 3d403bd685 feat: Phase 1 code-agent capabilities — file_edit, git tools, parallel execution, tool output preview
New tools:
- file_edit: surgical line-range or pattern edits (yolo only)
- git_status: show changed files (read-only)
- git_diff: show unstaged/staged diff (read-only)
- git_log: show recent commits (read-only)

Agent improvements:
- parallel execution for independent read-only tool calls
- mutating tools (file_write, file_edit, shell_run) run sequentially
- tool output preview in logs with [output] marker

TUI improvements:
- tool cards now show truncated output preview (up to 8 lines)
- mergeToolLog updates placeholder cards with results
- toolStatusText keeps activity bar single-line

Documentation:
- README updated with git tools and file_edit descriptions
2026-06-24 19:47:14 -07:00
loveuer 7b8bad5e62 feat: crush-inspired TUI improvements, activity bar polish, startup fresh session
TUI improvements:
- semantic icon constants (✓ ◆ ▣ ✕ ⌘ ◌ ◔ ◉)
- pill-style metadata bar with model/provider/session/context
- fuzzy command palette with deterministic subsequence scoring
- input history navigation (ctrl+p / ctrl+n)
- structured tool log cards (▣ tool_name + args preview)
- no-background activity indicator and meta bar for cleaner rendering

Startup behavior:
- default startup creates a fresh session
- explicit --resume <id> restores saved context
- added main_test.go for initializeSession coverage
2026-06-24 18:16:53 -07:00
22 changed files with 2864 additions and 405 deletions
+19 -4
View File
@@ -23,6 +23,12 @@ export AGENTU_API_KEY='your-api-key'
go run .
```
Use dark mode when your terminal is dark:
```sh
go run . --theme dark
```
Use `--yolo` only when you want agentu to write files or run shell commands:
```sh
@@ -94,22 +100,25 @@ go run . [flags]
## TUI Controls
- `enter` sends the message.
- `ctrl+j` or `alt+enter` inserts a newline.
- `ctrl+j` inserts a newline.
- `up` recalls the previous input; `ctrl+p` / `ctrl+n` navigate input history.
- The input starts at one line and grows up to six lines.
- `page up` / `page down` scrolls the transcript.
- `esc` cancels a running response.
- Type `/` to show available commands.
- In `--yolo` mode, type `! <command>` to run a shell command from the configured working directory.
Slash commands:
- `/clear` resets conversation history.
- `/compact` summarizes older conversation history and keeps recent context.
- `/compact` summarizes older conversation history and keeps the newest ~20% of context, with a small recent-message floor.
- `/status` shows changed files with `git status --short`.
- `/diff [--stat] [path...]` shows the unstaged git diff.
- `/test [command...]` runs a project test command; defaults to `go test ./...` for Go modules or `npm test` for Node projects.
- `/model` shows current provider, model, thinking, and switch usage.
- `/model provider <name>` switches provider for the current session.
- `/model model <name>` or `/model <name>` switches model for the current session.
In the TUI, type `/model ` or `/model model ` to choose from configured model IDs in the candidate palette.
- `/model thinking <none|middle|high|xhigh|max>` changes thinking for the current session.
- `/sessions` lists saved sessions.
- `/resume [id]` resumes by ID or opens an interactive picker.
@@ -125,6 +134,9 @@ Read-only tools are available by default:
- `file_list`
- `file_search` with optional `context_lines` and `max_results`
- `code_symbols` for Go packages, imports, types, functions, and methods
- `git_status` shows changed files
- `git_diff` shows unstaged or staged diff output
- `git_log` shows recent commit history
Web tools are also available in read-only mode:
@@ -133,8 +145,9 @@ Web tools are also available in read-only mode:
`--yolo` additionally enables:
- `file_write`
- `shell_run`
- `file_write` for creating or replacing whole files
- `file_edit` for surgical line-range or pattern edits to existing files
- `shell_run` for non-interactive shell commands
Use `--yolo` for prompts that ask agentu to run or inspect local commands, for
example:
@@ -143,6 +156,8 @@ example:
请帮我运行 `go test ./...` 并总结结果
```
## Development
Run the verification suite:
+150 -19
View File
@@ -7,18 +7,23 @@ import (
"io"
"sort"
"strings"
"sync"
"time"
"agentu/pkg/llm"
"agentu/internal/tools"
"agentu/pkg/llm"
)
const (
defaultMaxToolRounds = 50
doomLoopThreshold = 3
compactRecentMessages = 6
compactMinRecentMessages = 6
compactRecentRetentionPercent = 20
summaryMessageLimit = 8000
contextSummaryPrefix = "[context summary]"
toolLogArgumentLimit = 400
toolLogOutputPreviewBytes = 4096
toolLogOutputMarker = "[output]"
summarizerSystemPrompt = "You are a conversation summarizer. Summarize the following conversation history into a concise but detailed summary. Preserve:\n" +
"- Key facts, decisions, and conclusions\n" +
@@ -29,6 +34,11 @@ const (
"Output only the summary, no preamble."
)
type toolExecutionResult struct {
call llm.ToolCall
output string
}
type Agent struct {
provider llm.Provider
providerName string
@@ -172,6 +182,61 @@ func (a *Agent) SetLastUsage(usage *llm.Usage) {
copy := *usage
a.lastUsage = &copy
}
// compactRetainedTokenBudget returns the number of estimated tokens to retain
// based on compactRecentRetentionPercent, using integer ceiling division.
func compactRetainedTokenBudget(totalTokens int) int {
if totalTokens <= 0 {
return 1
}
return (totalTokens*compactRecentRetentionPercent + 99) / 100
}
// compactTurnBoundary walks backward from start until it finds a user message
// or reaches prefixEnd, ensuring the retained suffix starts at a user turn.
func compactTurnBoundary(messages []llm.Message, prefixEnd int, start int) int {
for start > prefixEnd && messages[start].Role != llm.RoleUser {
start--
}
return start
}
// compactRecentStart returns the index where the retained recent suffix begins.
// It balances the 20% token-budget ratio with the compactMinRecentMessages floor
// and aligns to a user-turn boundary.
func compactRecentStart(messages []llm.Message, prefixEnd int) int {
compactableCount := len(messages) - prefixEnd
if compactableCount <= compactMinRecentMessages {
return len(messages)
}
compactable := messages[prefixEnd:]
totalTokens := llm.EstimateTokens(compactable, nil)
retainTokens := compactRetainedTokenBudget(totalTokens)
// Walk backward from the end to find the earliest suffix fitting the budget.
ratioStart := len(messages) - 1
for i := len(messages) - 1; i >= prefixEnd; i-- {
if llm.EstimateTokens(messages[i:], nil) <= retainTokens {
ratioStart = i
} else {
break
}
}
// Enforce the minimum recent-message floor.
minStart := len(messages) - compactMinRecentMessages
if minStart < prefixEnd {
minStart = prefixEnd
}
// Choose the earlier start so the floor is never weakened.
start := minStart
if ratioStart < start {
start = ratioStart
}
return compactTurnBoundary(messages, prefixEnd, start)
}
// Compact summarizes older conversation messages while preserving the system
// prompt and recent messages intact.
@@ -187,12 +252,8 @@ func (a *Agent) Compact(ctx context.Context) error {
if a.messages[0].Role == llm.RoleSystem {
prefixEnd = 1
}
if len(a.messages)-prefixEnd <= compactRecentMessages {
return nil
}
recentStart := len(a.messages) - compactRecentMessages
if recentStart <= prefixEnd {
recentStart := compactRecentStart(a.messages, prefixEnd)
if recentStart <= prefixEnd || recentStart >= len(a.messages) {
return nil
}
middle := append([]llm.Message(nil), a.messages[prefixEnd:recentStart]...)
@@ -364,14 +425,15 @@ func (a *Agent) RunTurn(ctx context.Context, input string, out io.Writer, logs i
}
}
for _, call := range calls {
result := a.executeTool(ctx, call, logs)
results := a.executeTools(ctx, calls, logs)
for _, result := range results {
a.messages = append(a.messages, llm.Message{
Role: llm.RoleTool,
ToolCallID: call.ID,
Content: result,
ToolCallID: result.call.ID,
Content: result.output,
})
}
}
return fmt.Errorf("tool round limit reached after %d rounds; the task may be too complex for a single turn", a.maxToolRounds)
@@ -433,11 +495,8 @@ func (a *Agent) toolDefinitions() []llm.Tool {
return a.toolRegistry.Definitions()
}
func (a *Agent) executeTool(ctx context.Context, call llm.ToolCall, logs io.Writer) string {
func (a *Agent) executeTool(ctx context.Context, call llm.ToolCall) string {
name := call.Function.Name
if logs != nil {
fmt.Fprintf(logs, "\n[tool] %s %s\n", name, compact(call.Function.Arguments, 400))
}
if a.toolRegistry == nil {
return tools.FormatError(fmt.Errorf("tool registry is disabled"))
@@ -461,12 +520,84 @@ func (a *Agent) executeTool(ctx context.Context, call llm.ToolCall, logs io.Writ
}
return tools.FormatError(err)
}
if logs != nil {
fmt.Fprintf(logs, "[tool] %s done\n", name)
}
return output
}
func (a *Agent) hasMutatingCall(calls []llm.ToolCall) bool {
if a.toolRegistry == nil {
return false
}
for _, call := range calls {
if tool, ok := a.toolRegistry.Get(call.Function.Name); ok {
if tools.IsMutating(tool) {
return true
}
}
}
return false
}
func (a *Agent) executeTools(ctx context.Context, calls []llm.ToolCall, logs io.Writer) []toolExecutionResult {
results := make([]toolExecutionResult, len(calls))
// Log all tool starts sequentially so TUI gets ordered placeholders.
if logs != nil {
for _, call := range calls {
logToolStart(logs, call)
}
}
if a.hasMutatingCall(calls) {
// Sequential: avoid racing mutating tools.
for i, call := range calls {
output := a.executeTool(ctx, call)
results[i] = toolExecutionResult{call: call, output: output}
if logs != nil {
logToolOutput(logs, call, output)
}
}
return results
}
// Concurrent: independent read-only tools.
var mu sync.Mutex
var wg sync.WaitGroup
for i, call := range calls {
wg.Add(1)
go func(idx int, c llm.ToolCall) {
defer wg.Done()
output := a.executeTool(ctx, c)
results[idx] = toolExecutionResult{call: c, output: output}
if logs != nil {
mu.Lock()
logToolOutput(logs, c, output)
mu.Unlock()
}
}(i, call)
}
wg.Wait()
return results
}
func logToolStart(logs io.Writer, call llm.ToolCall) {
fmt.Fprintf(logs, "\n[tool] %s %s\n", call.Function.Name, compact(call.Function.Arguments, toolLogArgumentLimit))
}
func logToolOutput(logs io.Writer, call llm.ToolCall, output string) {
fmt.Fprintf(logs, "\n[tool] %s %s\n%s\n%s\n", call.Function.Name, compact(call.Function.Arguments, toolLogArgumentLimit), toolLogOutputMarker, toolOutputPreview(output))
}
func toolOutputPreview(output string) string {
output = strings.TrimSpace(output)
if output == "" {
return "(no output)"
}
if len(output) <= toolLogOutputPreviewBytes {
return output
}
return output[:toolLogOutputPreviewBytes] + fmt.Sprintf("\n[truncated: showing first %d bytes; omitted %d bytes]", toolLogOutputPreviewBytes, len(output)-toolLogOutputPreviewBytes)
}
type toolCallBuilder struct {
index int
id string
+217 -1
View File
@@ -7,8 +7,8 @@ import (
"strings"
"testing"
"agentu/pkg/llm"
"agentu/internal/tools"
"agentu/pkg/llm"
)
type fakeProvider struct {
@@ -145,6 +145,9 @@ func TestAgentCompact(t *testing.T) {
if messages[len(messages)-1].Content != "recent-answer-2" {
t.Fatalf("last recent message = %#v", messages[len(messages)-1])
}
if messages[2].Content != "recent-user-0" {
t.Fatalf("first retained recent message = %#v", messages[2])
}
if usage := a.LastUsage(); usage == nil || usage.TotalTokens != 12 {
t.Fatalf("usage = %#v", usage)
}
@@ -217,6 +220,48 @@ func longConversation() []llm.Message {
return messages
}
func manyTurnConversation(turns int) []llm.Message {
messages := []llm.Message{{Role: llm.RoleSystem, Content: "system"}}
for i := 0; i < turns; i++ {
messages = append(messages,
llm.Message{Role: llm.RoleUser, Content: fmt.Sprintf("user-%02d", i)},
llm.Message{Role: llm.RoleAssistant, Content: fmt.Sprintf("assistant-%02d", i)},
)
}
return messages
}
func TestAgentCompactKeepsTwentyPercentRecentContext(t *testing.T) {
provider := &compactProvider{}
a := New(Options{Provider: provider, Model: "test", SystemPrompt: "system"})
a.SetMessages(manyTurnConversation(25))
if err := a.Compact(context.Background()); err != nil {
t.Fatal(err)
}
messages := a.Messages()
// 1 system + 1 summary + 10 retained recent messages (5 user/assistant turns)
if len(messages) != 12 {
t.Fatalf("messages = %d, want 12; contents: %v", len(messages), joinMessageContents(messages))
}
if provider.summaryCalls != 1 {
t.Fatalf("summaryCalls = %d, want 1", provider.summaryCalls)
}
if messages[2].Content != "user-20" {
t.Fatalf("first retained message = %#v, want user-20", messages[2])
}
if messages[len(messages)-1].Content != "assistant-24" {
t.Fatalf("last retained message = %#v, want assistant-24", messages[len(messages)-1])
}
if !strings.Contains(provider.lastSummary, "user-19") {
t.Fatalf("summary should contain user-19 (part of summarized 80%%): %q", provider.lastSummary)
}
if strings.Contains(provider.lastSummary, "user-20") {
t.Fatalf("summary should not contain user-20 (part of retained 20%%): %q", provider.lastSummary)
}
}
func joinMessageContents(messages []llm.Message) string {
var b strings.Builder
for _, msg := range messages {
@@ -340,3 +385,174 @@ func TestAgentHardLimitStopsVaryingCalls(t *testing.T) {
t.Fatalf("calls = %d, want %d", provider.calls, limit)
}
}
// slowTool blocks on a channel before returning.
type slowTool struct {
name string
started chan<- string
release <-chan struct{}
}
func (t *slowTool) Definition() llm.Tool {
return llm.Tool{
Type: "function",
Function: llm.ToolFunction{
Name: t.name,
Description: "slow test tool",
Parameters: json.RawMessage(`{"type":"object","properties":{"text":{"type":"string"}}}`),
},
}
}
func (t *slowTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
var args struct {
Text string `json:"text"`
}
if err := json.Unmarshal(raw, &args); err != nil {
return "", err
}
t.started <- t.name
<-t.release
return args.Text, nil
}
// mutatingSlowTool is like slowTool but implements MutatingTool.
type mutatingSlowTool struct {
slowTool
}
func (*mutatingSlowTool) Mutates() bool { return true }
func TestAgentExecutesIndependentToolCallsInParallel(t *testing.T) {
started := make(chan string, 2)
release := make(chan struct{})
tool1 := &slowTool{name: "slow_one", started: started, release: release}
tool2 := &slowTool{name: "slow_two", started: started, release: release}
provider := &parallelProvider{
responses: [][]llm.ToolCallDelta{
{
{Index: 0, ID: "call_1", Type: "function", Name: "slow_one", Arguments: `{"text":"one"}`},
{Index: 1, ID: "call_2", Type: "function", Name: "slow_two", Arguments: `{"text":"two"}`},
},
},
finalContent: "done",
}
a := New(Options{
Provider: provider,
Model: "test",
SystemPrompt: "system",
ToolRegistry: tools.NewRegistry(tool1, tool2),
})
var out strings.Builder
done := make(chan error, 1)
go func() {
done <- a.RunTurn(context.Background(), "run both", &out, nil)
}()
// Both tools must start before we release them.
names := make(map[string]bool)
for i := 0; i < 2; i++ {
select {
case name := <-started:
names[name] = true
case <-make(chan struct{}):
// Use a timer in real code, but for test simplicity we rely on deadlock detection.
t.Fatal("timed out waiting for tool to start")
}
}
if !names["slow_one"] || !names["slow_two"] {
t.Fatalf("expected both tools to start, got %v", names)
}
close(release)
if err := <-done; err != nil {
t.Fatal(err)
}
if out.String() != "done" {
t.Fatalf("out = %q", out.String())
}
// Verify tool results were in order.
msgs := a.Messages()
var toolMsgs []string
for _, m := range msgs {
if m.Role == llm.RoleTool {
toolMsgs = append(toolMsgs, m.Content)
}
}
if len(toolMsgs) != 2 || toolMsgs[0] != "one" || toolMsgs[1] != "two" {
t.Fatalf("tool messages = %v, want [one two]", toolMsgs)
}
}
func TestAgentRunsMutatingToolCallsSequentially(t *testing.T) {
started := make(chan string, 2)
release := make(chan struct{})
tool1 := &mutatingSlowTool{slowTool{name: "mut_one", started: started, release: release}}
tool2 := &mutatingSlowTool{slowTool{name: "mut_two", started: started, release: release}}
provider := &parallelProvider{
responses: [][]llm.ToolCallDelta{
{
{Index: 0, ID: "call_1", Type: "function", Name: "mut_one", Arguments: `{"text":"one"}`},
{Index: 1, ID: "call_2", Type: "function", Name: "mut_two", Arguments: `{"text":"two"}`},
},
},
finalContent: "done",
}
a := New(Options{
Provider: provider,
Model: "test",
SystemPrompt: "system",
ToolRegistry: tools.NewRegistry(tool1, tool2),
})
var out strings.Builder
done := make(chan error, 1)
go func() {
done <- a.RunTurn(context.Background(), "run both", &out, nil)
}()
// Only the first tool should start.
select {
case name := <-started:
if name != "mut_one" {
t.Fatalf("expected mut_one to start first, got %q", name)
}
case <-make(chan struct{}):
t.Fatal("timed out waiting for first tool to start")
}
// Second tool should NOT have started yet.
select {
case name := <-started:
t.Fatalf("second tool started too early: %q", name)
default:
// Expected: second tool hasn't started.
}
close(release)
if err := <-done; err != nil {
t.Fatal(err)
}
}
// parallelProvider emits a batch of tool calls on the first request, then returns finalContent.
type parallelProvider struct {
responses [][]llm.ToolCallDelta
finalContent string
calls int
}
func (p *parallelProvider) ChatStream(ctx context.Context, req llm.ChatRequest, emit func(llm.StreamEvent) error) error {
p.calls++
if p.calls <= len(p.responses) {
return emit(llm.StreamEvent{ToolCalls: p.responses[p.calls-1]})
}
return emit(llm.StreamEvent{Content: p.finalContent})
}
+164
View File
@@ -0,0 +1,164 @@
package app
import (
"context"
"errors"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"agentu/internal/agent"
"agentu/internal/session"
"agentu/internal/tools"
"agentu/internal/tui"
"agentu/pkg/config"
"agentu/pkg/llm"
)
type cliOptions struct {
configPath string
yolo bool
plain bool
themeName string
resumeID string
}
func Run() error {
opts := parseCLIOptions()
themeMode, err := tui.ParseThemeMode(opts.themeName)
if err != nil {
return err
}
cfg, err := loadConfig(opts.configPath)
if err != nil {
return err
}
providerName, providerConfig, provider := defaultProvider(cfg, http.DefaultClient)
assistant := newAssistant(cfg, providerName, providerConfig, provider, opts.yolo, http.DefaultClient)
modelManager, err := newSessionManager(cfg, assistant, http.DefaultClient)
if err != nil {
return err
}
if err := initializeSession(modelManager, opts.resumeID); err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
return runInterface(ctx, assistant, modelManager, providerConfig, cfg.Agent.WorkingDir, opts, themeMode)
}
func parseCLIOptions() cliOptions {
configPath := flag.String("config", config.DefaultPath, "path to YAML config")
yolo := flag.Bool("yolo", false, "also enable automatic file writes and shell execution")
plain := flag.Bool("plain", false, "use the simple line-based REPL instead of the TUI")
themeName := flag.String("theme", "light", "TUI theme: light or dark")
resumeID := flag.String("resume", "", "resume a specific session by ID")
flag.Parse()
return cliOptions{
configPath: *configPath,
yolo: *yolo,
plain: *plain,
themeName: *themeName,
resumeID: *resumeID,
}
}
func loadConfig(configPath string) (*config.Config, error) {
cfg, err := config.Load(configPath)
if err == nil {
return cfg, nil
}
if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
if bootstrapErr := bootstrapConfig(configPath); bootstrapErr != nil {
return nil, bootstrapErr
}
return nil, fmt.Errorf("created default config at %s; edit it to set your API key and provider, then restart agentu", configPath)
}
func defaultProvider(cfg *config.Config, httpClient *http.Client) (string, config.ProviderConfig, llm.Provider) {
providerName := cfg.DefaultProviderName()
providerConfig := cfg.Providers[providerName]
provider := llm.NewOpenAICompatibleClient(providerConfig.BaseURL, providerConfig.APIKey, httpClient)
return providerName, providerConfig, provider
}
func newAssistant(cfg *config.Config, providerName string, providerConfig config.ProviderConfig, provider llm.Provider, yolo bool, httpClient *http.Client) *agent.Agent {
return agent.New(agent.Options{
Provider: provider,
ProviderName: providerName,
Model: providerConfig.Model,
Thinking: providerConfig.Thinking,
ThinkingParam: providerConfig.ThinkingParam,
ThinkingEnabled: providerConfig.ThinkingConfigured,
SystemPrompt: systemPrompt(cfg, yolo),
ToolRegistry: newToolRegistry(cfg.Agent.WorkingDir, yolo, httpClient),
ToolTimeout: cfg.Agent.ToolTimeout,
MaxContextTokens: maxContextTokens(cfg, providerConfig),
})
}
func systemPrompt(cfg *config.Config, yolo bool) string {
instructions := tools.AgentInstructions(cfg.Agent.WorkingDir, yolo)
return strings.TrimSpace(cfg.Agent.SystemPrompt + "\n\n" + instructions)
}
func maxContextTokens(cfg *config.Config, providerConfig config.ProviderConfig) int {
if cfg.Agent.MaxContextTokens != 0 {
return cfg.Agent.MaxContextTokens
}
return providerConfig.ContextTokens
}
func newToolRegistry(workingDir string, yolo bool, httpClient *http.Client) *tools.Registry {
var registry *tools.Registry
if yolo {
registry = tools.Builtins(workingDir)
} else {
registry = tools.ReadOnlyBuiltins(workingDir)
}
registry.Register(tools.NewBuiltinSearchTool(httpClient))
registry.Register(tools.NewBuiltinFetchTool(httpClient))
return registry
}
func newSessionManager(cfg *config.Config, assistant *agent.Agent, httpClient *http.Client) (*session.Manager, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("resolve home dir: %w", err)
}
sessionsDir := filepath.Join(home, ".agentu", "sessions")
store, err := session.NewStore(sessionsDir)
if err != nil {
return nil, fmt.Errorf("init session store: %w", err)
}
return session.NewManager(cfg, assistant, httpClient, store)
}
func runInterface(ctx context.Context, assistant *agent.Agent, modelManager *session.Manager, providerConfig config.ProviderConfig, workingDir string, opts cliOptions, themeMode tui.ThemeMode) error {
if opts.plain {
return repl(ctx, assistant, modelManager)
}
err := tui.Run(ctx, assistant, tui.Options{
ModelName: providerConfig.Model,
Yolo: opts.yolo,
ThemeMode: themeMode,
ModelManager: modelManager,
WorkingDir: workingDir,
})
printExitSession(modelManager)
return err
}
+38
View File
@@ -0,0 +1,38 @@
package app
import (
"fmt"
"os"
"path/filepath"
"agentu/pkg/config"
)
const defaultConfigContent = `# agentu configuration
# Replace the placeholder values below with your actual provider settings.
# See agentu.example.yaml for the full reference.
providers:
default:
base_url: https://api.openai.com
api_key: YOUR_API_KEY_HERE
models:
- id: gpt-4o
context: 128000
- id: gpt-4o-mini
context: 128000
`
func bootstrapConfig(configPath string) error {
resolved, err := config.ResolvePath(configPath)
if err != nil {
return fmt.Errorf("resolve config path: %w", err)
}
dir := filepath.Dir(resolved)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create config directory: %w", err)
}
if err := os.WriteFile(resolved, []byte(defaultConfigContent), 0o644); err != nil {
return fmt.Errorf("write default config: %w", err)
}
return nil
}
+101
View File
@@ -0,0 +1,101 @@
package app
import (
"context"
"path/filepath"
"testing"
"agentu/internal/agent"
"agentu/internal/session"
"agentu/pkg/config"
"agentu/pkg/llm"
)
type startupProvider struct{}
func (p *startupProvider) ChatStream(ctx context.Context, req llm.ChatRequest, emit func(llm.StreamEvent) error) error {
return emit(llm.StreamEvent{Content: "ok"})
}
func startupTestManager(t *testing.T) (*session.Manager, *agent.Agent) {
t.Helper()
cfg := &config.Config{
Providers: map[string]config.ProviderConfig{
"one": {
Name: "one",
BaseURL: "https://one.example.com",
APIKey: "sk-test",
Model: "model-a",
Models: []string{"model-a"},
Thinking: "none",
ThinkingParam: "thinking",
},
},
}
assistant := agent.New(agent.Options{
Provider: &startupProvider{},
ProviderName: "one",
Model: "model-a",
})
store, err := session.NewStore(filepath.Join(t.TempDir(), "sessions"))
if err != nil {
t.Fatal(err)
}
manager, err := session.NewManager(cfg, assistant, nil, store)
if err != nil {
t.Fatal(err)
}
return manager, assistant
}
func TestInitializeSessionStartsNewByDefault(t *testing.T) {
manager, assistant := startupTestManager(t)
if _, err := manager.NewSession(); err != nil {
t.Fatalf("new session: %v", err)
}
savedID := manager.CurrentSessionID()
assistant.SetMessages([]llm.Message{{Role: llm.RoleUser, Content: "old context"}})
if err := manager.Save(); err != nil {
t.Fatalf("save: %v", err)
}
if err := initializeSession(manager, ""); err != nil {
t.Fatalf("initialize: %v", err)
}
if manager.CurrentSessionID() == savedID {
t.Fatalf("default startup resumed latest session %s", savedID)
}
for _, msg := range assistant.Messages() {
if msg.Content == "old context" {
t.Fatal("default startup carried old context into new session")
}
}
}
func TestInitializeSessionResumesExplicitID(t *testing.T) {
manager, assistant := startupTestManager(t)
if _, err := manager.NewSession(); err != nil {
t.Fatalf("new session: %v", err)
}
savedID := manager.CurrentSessionID()
assistant.SetMessages([]llm.Message{{Role: llm.RoleUser, Content: "old context"}})
if err := manager.Save(); err != nil {
t.Fatalf("save: %v", err)
}
if err := initializeSession(manager, savedID); err != nil {
t.Fatalf("initialize: %v", err)
}
if manager.CurrentSessionID() != savedID {
t.Fatalf("expected resumed session %s, got %s", savedID, manager.CurrentSessionID())
}
found := false
for _, msg := range assistant.Messages() {
if msg.Content == "old context" {
found = true
}
}
if !found {
t.Fatal("explicit resume did not restore saved context")
}
}
+60
View File
@@ -0,0 +1,60 @@
package app
import (
"bufio"
"context"
"fmt"
"os"
"strings"
"agentu/internal/agent"
"agentu/internal/session"
)
func repl(ctx context.Context, assistant *agent.Agent, modelManager *session.Manager) error {
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
fmt.Println("agentu")
fmt.Println("Type /exit to quit, /clear to reset context, /compact to compress context.")
for {
fmt.Print("> ")
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return err
}
fmt.Println()
printExitSession(modelManager)
return nil
}
input := strings.TrimSpace(scanner.Text())
switch input {
case "":
continue
case "/exit", "/quit":
printExitSession(modelManager)
return nil
case "/clear":
assistant.Clear()
fmt.Println("context cleared")
continue
case "/compact":
if err := assistant.Compact(ctx); err != nil {
fmt.Fprintln(os.Stderr, "compact error:", err)
} else {
fmt.Println("context compacted")
if modelManager != nil {
_ = modelManager.Save()
}
}
continue
}
if err := assistant.RunTurn(ctx, input, os.Stdout, os.Stderr); err != nil {
fmt.Fprintln(os.Stderr, "\nerror:", err)
}
_ = modelManager.Save()
fmt.Println()
}
}
+38
View File
@@ -0,0 +1,38 @@
package app
import (
"fmt"
"os"
"agentu/internal/session"
)
func initializeSession(modelManager *session.Manager, resumeID string) error {
if resumeID != "" {
if err := modelManager.Resume(resumeID); err != nil {
return fmt.Errorf("resume session: %w", err)
}
return nil
}
if _, err := modelManager.NewSession(); err != nil {
return fmt.Errorf("start new session: %w", err)
}
return nil
}
func printExitSession(m *session.Manager) {
if m == nil {
return
}
_ = m.Save()
id := m.CurrentSessionID()
name := m.CurrentSessionName()
if id != "" {
if name != "" && name != id {
fmt.Fprintf(os.Stderr, "session: %s (%s)\n", id, name)
} else {
fmt.Fprintf(os.Stderr, "session: %s\n", id)
}
fmt.Fprintf(os.Stderr, "resume: agentu --resume %s\n", id)
}
}
+6 -2
View File
@@ -288,9 +288,13 @@ func (m *Manager) providerNames() []string {
return names
}
func (m *Manager) AvailableModels() []string {
return append([]string(nil), m.provider.Models...)
}
func contains(values []string, value string) bool {
for _, item := range values {
if item == value {
for _, v := range values {
if v == value {
return true
}
}
+12
View File
@@ -109,6 +109,18 @@ func TestManagerRejectsUnknownModelAndThinking(t *testing.T) {
}
}
func TestManagerExposesModelCommandCandidates(t *testing.T) {
manager, _, _ := testManager(t)
models := manager.AvailableModels()
if len(models) != 2 || models[0] != "model-a" || models[1] != "model-b" {
t.Fatalf("AvailableModels() = %v", models)
}
models[0] = "mutated"
if got := manager.AvailableModels()[0]; got != "model-a" {
t.Fatalf("mutation leaked: %q", got)
}
}
func TestManagerSaveAndResume(t *testing.T) {
manager, assistant, _ := testManager(t)
+1
View File
@@ -81,6 +81,7 @@ type FileWriteTool struct {
func NewFileWriteTool(workingDir string) *FileWriteTool {
return &FileWriteTool{workingDir: workingDir}
}
func (*FileWriteTool) Mutates() bool { return true }
func (t *FileWriteTool) Definition() llm.Tool {
return llm.Tool{
+178
View File
@@ -0,0 +1,178 @@
package tools
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"agentu/pkg/llm"
)
type FileEditTool struct {
workingDir string
}
func NewFileEditTool(workingDir string) *FileEditTool {
return &FileEditTool{workingDir: workingDir}
}
func (*FileEditTool) Mutates() bool { return true }
func (t *FileEditTool) Definition() llm.Tool {
return llm.Tool{
Type: "function",
Function: llm.ToolFunction{
Name: "file_edit",
Description: "Edit an existing UTF-8 text file by replacing an inclusive 1-based line range or an exact old text pattern.",
Parameters: JSONSchema(`{
"type": "object",
"properties": {
"path": {"type": "string", "description": "Existing file path relative to the configured agent working directory."},
"start_line": {"type": "integer", "description": "1-based first line for a line-range edit."},
"end_line": {"type": "integer", "description": "Optional 1-based final line; defaults to start_line when omitted."},
"old": {"type": "string", "description": "Exact text to replace for a pattern edit; not regex."},
"new": {"type": "string", "description": "Replacement text; may be empty string for deletion."},
"replace_all": {"type": "boolean", "description": "When true, replace every exact old occurrence; otherwise require exactly one occurrence."}
},
"required": ["path", "new"],
"additionalProperties": false
}`),
},
}
}
type fileEditArgs struct {
Path string `json:"path"`
StartLine int `json:"start_line"`
EndLine int `json:"end_line"`
Old string `json:"old"`
New *string `json:"new"`
ReplaceAll bool `json:"replace_all"`
}
func (t *FileEditTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
var args fileEditArgs
if err := json.Unmarshal(raw, &args); err != nil {
return "", fmt.Errorf("parse arguments: %w", err)
}
if strings.TrimSpace(args.Path) == "" {
return "", errors.New("path is required")
}
if args.New == nil {
return "", errors.New("new is required")
}
path, err := resolvePath(t.workingDir, args.Path)
if err != nil {
return "", err
}
info, err := os.Stat(path)
if err != nil {
return "", fmt.Errorf("cannot access %s: %w", args.Path, err)
}
if info.IsDir() {
return "", fmt.Errorf("%s is a directory, not a file", args.Path)
}
perm := info.Mode().Perm()
hasLine := args.StartLine > 0 || args.EndLine > 0
hasOld := strings.TrimSpace(args.Old) != ""
if hasLine && hasOld {
return "", errors.New("provide either start_line/end_line or old, not both")
}
if !hasLine && !hasOld {
return "", errors.New("provide either start_line/end_line or old")
}
if err := ctx.Err(); err != nil {
return "", err
}
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
content := string(data)
var result string
if hasOld {
result, err = editByPattern(content, args.Path, args.Old, *args.New, args.ReplaceAll)
} else {
result, err = editByLineRange(content, args.Path, args.StartLine, args.EndLine, *args.New)
}
if err != nil {
return "", err
}
if err := ctx.Err(); err != nil {
return "", err
}
if err := os.WriteFile(path, []byte(result), perm); err != nil {
return "", err
}
switch {
case hasOld:
count := strings.Count(content, args.Old)
if args.ReplaceAll {
return fmt.Sprintf("edited %s: replaced %d occurrences", args.Path, count), nil
}
return fmt.Sprintf("edited %s: replaced 1 occurrence", args.Path), nil
default:
endLine := args.EndLine
if endLine == 0 {
endLine = args.StartLine
}
return fmt.Sprintf("edited %s: replaced lines %d-%d", args.Path, args.StartLine, endLine), nil
}
}
func editByLineRange(content, displayPath string, startLine, endLine int, replacement string) (string, error) {
if startLine < 1 {
return "", errors.New("start_line must be >= 1")
}
if endLine == 0 {
endLine = startLine
}
if endLine < startLine {
return "", fmt.Errorf("end_line (%d) must be >= start_line (%d)", endLine, startLine)
}
lines := strings.Split(content, "\n")
if startLine > len(lines) {
return "", fmt.Errorf("line range %d-%d is outside file with %d lines", startLine, endLine, len(lines))
}
if endLine > len(lines) {
return "", fmt.Errorf("line range %d-%d is outside file with %d lines", startLine, endLine, len(lines))
}
var b strings.Builder
b.WriteString(strings.Join(lines[:startLine-1], "\n"))
if startLine > 1 {
b.WriteString("\n")
}
b.WriteString(replacement)
if endLine < len(lines) {
if !strings.HasSuffix(replacement, "\n") && replacement != "" {
b.WriteString("\n")
}
b.WriteString(strings.Join(lines[endLine:], "\n"))
}
return b.String(), nil
}
func editByPattern(content, displayPath, old, replacement string, replaceAll bool) (string, error) {
count := strings.Count(content, old)
if count == 0 {
return "", errors.New("old text not found")
}
if !replaceAll && count != 1 {
return "", fmt.Errorf("old text matched %d occurrences; set replace_all=true or provide a more specific old value", count)
}
return strings.ReplaceAll(content, old, replacement), nil
}
+136
View File
@@ -0,0 +1,136 @@
package tools
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestFileEditReplacesLineRange(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "notes.txt")
if err := os.WriteFile(path, []byte("one\ntwo\nthree\n"), 0o644); err != nil {
t.Fatal(err)
}
edit := NewFileEditTool(dir)
out, err := edit.Execute(context.Background(), json.RawMessage(`{"path":"notes.txt","start_line":2,"end_line":2,"new":"TWO\n"}`))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "replaced lines 2-2") {
t.Fatalf("output = %q", out)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(got) != "one\nTWO\nthree\n" {
t.Fatalf("file content = %q", string(got))
}
}
func TestFileEditReplacesExactPattern(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "notes.txt")
if err := os.WriteFile(path, []byte("alpha beta alpha"), 0o644); err != nil {
t.Fatal(err)
}
edit := NewFileEditTool(dir)
out, err := edit.Execute(context.Background(), json.RawMessage(`{"path":"notes.txt","old":"beta","new":"BETA"}`))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "replaced 1 occurrence") {
t.Fatalf("output = %q", out)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(got) != "alpha BETA alpha" {
t.Fatalf("file content = %q", string(got))
}
}
func TestFileEditRejectsAmbiguousPattern(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "notes.txt")
if err := os.WriteFile(path, []byte("x x"), 0o644); err != nil {
t.Fatal(err)
}
edit := NewFileEditTool(dir)
_, err := edit.Execute(context.Background(), json.RawMessage(`{"path":"notes.txt","old":"x","new":"y"}`))
if err == nil {
t.Fatal("expected error for ambiguous pattern")
}
if !strings.Contains(err.Error(), "matched 2 occurrences") {
t.Fatalf("error = %v", err)
}
}
func TestFileEditReplaceAll(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "notes.txt")
if err := os.WriteFile(path, []byte("x x"), 0o644); err != nil {
t.Fatal(err)
}
edit := NewFileEditTool(dir)
out, err := edit.Execute(context.Background(), json.RawMessage(`{"path":"notes.txt","old":"x","new":"y","replace_all":true}`))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "replaced 2 occurrences") {
t.Fatalf("output = %q", out)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(got) != "y y" {
t.Fatalf("file content = %q", string(got))
}
}
func TestFileEditRejectsEscapingPath(t *testing.T) {
dir := t.TempDir()
edit := NewFileEditTool(dir)
_, err := edit.Execute(context.Background(), json.RawMessage(`{"path":"../outside.txt","old":"x","new":"y"}`))
if err == nil {
t.Fatal("expected error for escaping path")
}
if !strings.Contains(err.Error(), "escapes") {
t.Fatalf("error = %v", err)
}
}
func TestFileEditRejectsMissingNew(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "notes.txt")
if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
edit := NewFileEditTool(dir)
_, err := edit.Execute(context.Background(), json.RawMessage(`{"path":"notes.txt","old":"hello"}`))
if err == nil {
t.Fatal("expected error for missing new")
}
if !strings.Contains(err.Error(), "new is required") {
t.Fatalf("error = %v", err)
}
}
func TestFileEditRejectsNonExistentFile(t *testing.T) {
dir := t.TempDir()
edit := NewFileEditTool(dir)
_, err := edit.Execute(context.Background(), json.RawMessage(`{"path":"missing.txt","old":"x","new":"y"}`))
if err == nil {
t.Fatal("expected error for non-existent file")
}
}
+246
View File
@@ -0,0 +1,246 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"os/exec"
"path/filepath"
"strings"
"agentu/pkg/llm"
)
const (
defaultGitLogMaxCount = 20
maxGitLogMaxCount = 100
)
func runGit(ctx context.Context, workingDir string, args ...string) (string, error) {
gitArgs := append([]string{"-C", workingDir}, args...)
cmd := exec.CommandContext(ctx, "git", gitArgs...)
output, err := cmd.CombinedOutput()
text := FormatResult(string(output))
if err != nil {
if strings.Contains(err.Error(), "context deadline exceeded") || strings.Contains(err.Error(), "signal: killed") {
if text != "" {
return text, fmt.Errorf("git command timed out: %w", err)
}
return "", fmt.Errorf("git command timed out: %w", err)
}
if text != "" {
return text, fmt.Errorf("git command failed: %w", err)
}
return "", fmt.Errorf("git command failed: %w", err)
}
return text, nil
}
func gitPathspec(baseDir, path string) (string, error) {
if strings.TrimSpace(path) == "" {
return "", nil
}
resolved, err := resolvePath(baseDir, path)
if err != nil {
return "", err
}
rel, err := filepath.Rel(baseDir, resolved)
if err != nil {
return "", err
}
return filepath.ToSlash(filepath.Clean(rel)), nil
}
func normalizeGitLogMaxCount(value int) int {
if value <= 0 {
return defaultGitLogMaxCount
}
if value > maxGitLogMaxCount {
return maxGitLogMaxCount
}
return value
}
// git_status
type GitStatusTool struct {
workingDir string
}
func NewGitStatusTool(workingDir string) *GitStatusTool {
return &GitStatusTool{workingDir: workingDir}
}
func (t *GitStatusTool) Definition() llm.Tool {
return llm.Tool{
Type: "function",
Function: llm.ToolFunction{
Name: "git_status",
Description: "Show changed files using git status --short.",
Parameters: JSONSchema(`{
"type": "object",
"properties": {
"path": {"type": "string", "description": "Optional path to limit status to. Relative paths are resolved from the configured agent working directory."}
},
"additionalProperties": false
}`),
},
}
}
type gitStatusArgs struct {
Path string `json:"path"`
}
func (t *GitStatusTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
var args gitStatusArgs
if len(raw) > 0 {
if err := json.Unmarshal(raw, &args); err != nil {
return "", fmt.Errorf("parse arguments: %w", err)
}
}
gitArgs := []string{"status", "--short"}
if args.Path != "" {
pathspec, err := gitPathspec(t.workingDir, args.Path)
if err != nil {
return "", err
}
gitArgs = append(gitArgs, "--", pathspec)
}
text, err := runGit(ctx, t.workingDir, gitArgs...)
if err != nil {
return text, err
}
if strings.TrimSpace(text) == "" {
return "Working tree clean.", nil
}
return "Changed files:\n" + text, nil
}
// git_diff
type GitDiffTool struct {
workingDir string
}
func NewGitDiffTool(workingDir string) *GitDiffTool {
return &GitDiffTool{workingDir: workingDir}
}
func (t *GitDiffTool) Definition() llm.Tool {
return llm.Tool{
Type: "function",
Function: llm.ToolFunction{
Name: "git_diff",
Description: "Show unstaged or staged git diff output, optionally as --stat.",
Parameters: JSONSchema(`{
"type": "object",
"properties": {
"path": {"type": "string", "description": "Optional path to limit diff to. Relative paths are resolved from the configured agent working directory."},
"staged": {"type": "boolean", "description": "Show staged diff instead of unstaged."},
"stat": {"type": "boolean", "description": "Show diffstat summary instead of full diff."}
},
"additionalProperties": false
}`),
},
}
}
type gitDiffArgs struct {
Path string `json:"path"`
Staged bool `json:"staged"`
Stat bool `json:"stat"`
}
func (t *GitDiffTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
var args gitDiffArgs
if len(raw) > 0 {
if err := json.Unmarshal(raw, &args); err != nil {
return "", fmt.Errorf("parse arguments: %w", err)
}
}
gitArgs := []string{"diff"}
if args.Staged {
gitArgs = append(gitArgs, "--cached")
}
if args.Stat {
gitArgs = append(gitArgs, "--stat")
}
if args.Path != "" {
pathspec, err := gitPathspec(t.workingDir, args.Path)
if err != nil {
return "", err
}
gitArgs = append(gitArgs, "--", pathspec)
}
text, err := runGit(ctx, t.workingDir, gitArgs...)
if err != nil {
return text, err
}
if strings.TrimSpace(text) == "" {
if args.Staged {
return "No staged diff.", nil
}
return "No unstaged diff.", nil
}
return text, nil
}
// git_log
type GitLogTool struct {
workingDir string
}
func NewGitLogTool(workingDir string) *GitLogTool {
return &GitLogTool{workingDir: workingDir}
}
func (t *GitLogTool) Definition() llm.Tool {
return llm.Tool{
Type: "function",
Function: llm.ToolFunction{
Name: "git_log",
Description: "Show recent git commits with git log --oneline --decorate.",
Parameters: JSONSchema(`{
"type": "object",
"properties": {
"path": {"type": "string", "description": "Optional path to limit log to. Relative paths are resolved from the configured agent working directory."},
"max_count": {"type": "integer", "description": "Maximum number of commits to show. Defaults to 20, capped at 100."}
},
"additionalProperties": false
}`),
},
}
}
type gitLogArgs struct {
Path string `json:"path"`
MaxCount int `json:"max_count"`
}
func (t *GitLogTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
var args gitLogArgs
if len(raw) > 0 {
if err := json.Unmarshal(raw, &args); err != nil {
return "", fmt.Errorf("parse arguments: %w", err)
}
}
maxCount := normalizeGitLogMaxCount(args.MaxCount)
gitArgs := []string{"log", "--oneline", "--decorate", fmt.Sprintf("-n%d", maxCount)}
if args.Path != "" {
pathspec, err := gitPathspec(t.workingDir, args.Path)
if err != nil {
return "", err
}
gitArgs = append(gitArgs, "--", pathspec)
}
text, err := runGit(ctx, t.workingDir, gitArgs...)
if err != nil {
return text, err
}
if strings.TrimSpace(text) == "" {
return "No git history.", nil
}
return text, nil
}
+142
View File
@@ -0,0 +1,142 @@
package tools
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func requireGit(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
}
func runGitForTest(t *testing.T, dir string, args ...string) {
t.Helper()
gitArgs := append([]string{"-C", dir}, args...)
cmd := exec.Command("git", gitArgs...)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v failed: %v\n%s", args, err, out)
}
}
func initTestRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
runGitForTest(t, dir, "init")
runGitForTest(t, dir, "-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "--allow-empty", "-m", "initial")
return dir
}
func TestGitStatusToolShowsChangedFiles(t *testing.T) {
requireGit(t)
dir := initTestRepo(t)
path := filepath.Join(dir, "tracked.txt")
if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
runGitForTest(t, dir, "add", "tracked.txt")
runGitForTest(t, dir, "-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "-m", "add tracked")
if err := os.WriteFile(path, []byte("world"), 0o644); err != nil {
t.Fatal(err)
}
tool := NewGitStatusTool(dir)
out, err := tool.Execute(context.Background(), nil)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "Changed files:") {
t.Fatalf("output missing 'Changed files:': %q", out)
}
if !strings.Contains(out, "M tracked.txt") {
t.Fatalf("output missing 'M tracked.txt': %q", out)
}
}
func TestGitDiffToolShowsUnstagedDiff(t *testing.T) {
requireGit(t)
dir := initTestRepo(t)
path := filepath.Join(dir, "tracked.txt")
if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
runGitForTest(t, dir, "add", "tracked.txt")
runGitForTest(t, dir, "-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "-m", "add tracked")
if err := os.WriteFile(path, []byte("world"), 0o644); err != nil {
t.Fatal(err)
}
tool := NewGitDiffTool(dir)
out, err := tool.Execute(context.Background(), nil)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "diff --git") {
t.Fatalf("output missing 'diff --git': %q", out)
}
}
func TestGitDiffToolShowsStat(t *testing.T) {
requireGit(t)
dir := initTestRepo(t)
path := filepath.Join(dir, "tracked.txt")
if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
runGitForTest(t, dir, "add", "tracked.txt")
runGitForTest(t, dir, "-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "-m", "add tracked")
if err := os.WriteFile(path, []byte("world"), 0o644); err != nil {
t.Fatal(err)
}
tool := NewGitDiffTool(dir)
out, err := tool.Execute(context.Background(), json.RawMessage(`{"stat":true}`))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "tracked.txt") {
t.Fatalf("output missing 'tracked.txt': %q", out)
}
}
func TestGitLogToolShowsCommits(t *testing.T) {
requireGit(t)
dir := initTestRepo(t)
tool := NewGitLogTool(dir)
out, err := tool.Execute(context.Background(), json.RawMessage(`{"max_count":1}`))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "initial") {
t.Fatalf("output missing 'initial': %q", out)
}
}
func TestGitToolsRejectEscapingPath(t *testing.T) {
requireGit(t)
dir := initTestRepo(t)
tool := NewGitStatusTool(dir)
_, err := tool.Execute(context.Background(), json.RawMessage(`{"path":"../outside"}`))
if err == nil {
t.Fatal("expected error for escaping path")
}
if !strings.Contains(err.Error(), "escapes") {
t.Fatalf("error = %v", err)
}
}
+1
View File
@@ -19,6 +19,7 @@ type ShellRunTool struct {
func NewShellRunTool(workingDir string) *ShellRunTool {
return &ShellRunTool{workingDir: workingDir}
}
func (*ShellRunTool) Mutates() bool { return true }
func (t *ShellRunTool) Definition() llm.Tool {
return llm.Tool{
+27 -3
View File
@@ -19,6 +19,18 @@ type Tool interface {
Execute(ctx context.Context, args json.RawMessage) (string, error)
}
// MutatingTool is optionally implemented by tools that modify external state.
// It is used by the agent to determine whether tool calls can run in parallel.
type MutatingTool interface {
Mutates() bool
}
// IsMutating reports whether tool implements MutatingTool and returns true.
func IsMutating(tool Tool) bool {
mutating, ok := tool.(MutatingTool)
return ok && mutating.Mutates()
}
type Registry struct {
tools map[string]Tool
primary []string
@@ -66,30 +78,39 @@ func ReadOnlyBuiltins(workingDir string) *Registry {
fileList := NewFileListTool(workingDir)
fileSearch := NewFileSearchTool(workingDir)
codeSymbols := NewCodeSymbolsTool(workingDir)
gitStatus := NewGitStatusTool(workingDir)
gitDiff := NewGitDiffTool(workingDir)
gitLog := NewGitLogTool(workingDir)
registry := NewRegistry(fileRead, fileList, fileSearch, codeSymbols)
registry := NewRegistry(fileRead, fileList, fileSearch, codeSymbols, gitStatus, gitDiff, gitLog)
registry.RegisterAlias("file.read", fileRead)
registry.RegisterAlias("file.list", fileList)
registry.RegisterAlias("file.search", fileSearch)
registry.RegisterAlias("code.symbols", codeSymbols)
registry.RegisterAlias("git.status", gitStatus)
registry.RegisterAlias("git.diff", gitDiff)
registry.RegisterAlias("git.log", gitLog)
return registry
}
func Builtins(workingDir string) *Registry {
registry := ReadOnlyBuiltins(workingDir)
fileWrite := NewFileWriteTool(workingDir)
fileEdit := NewFileEditTool(workingDir)
shellRun := NewShellRunTool(workingDir)
registry.Register(fileWrite)
registry.Register(fileEdit)
registry.Register(shellRun)
registry.RegisterAlias("file.write", fileWrite)
registry.RegisterAlias("file.edit", fileEdit)
registry.RegisterAlias("shell.run", shellRun)
return registry
}
func AgentInstructions(workingDir string, yolo bool) string {
searchInstructions := webSearchInstructions()
readOnlyTools := "file_list, file_read, file_search, code_symbols, web_search, web_fetch"
yoloTools := "file_list, file_read, file_search, code_symbols, file_write, shell_run, web_search, web_fetch"
readOnlyTools := "file_list, file_read, file_search, code_symbols, git_status, git_diff, git_log, web_search, web_fetch"
yoloTools := "file_list, file_read, file_search, code_symbols, file_write, file_edit, shell_run, git_status, git_diff, git_log, web_search, web_fetch"
if yolo {
return fmt.Sprintf(`Local project context:
- The project working directory is %q.
@@ -99,7 +120,9 @@ func AgentInstructions(workingDir string, yolo bool) string {
- When the user asks to inspect, list, search, or read project files, use file_list, file_search, and file_read instead of saying you cannot access local files.
- When the user asks about code structure, symbols, functions, methods, types, or definitions in Go files, use code_symbols before reading whole files.
- When the user asks to run, execute, test, check, debug, or inspect a shell command, use shell_run. This includes local commands written in backticks, such as go, make, git, pwd, ls, ps, and similar terminal commands.
- When editing existing files, prefer file_edit for small or localized changes; use file_write only for creating files, replacing whole files intentionally, or appending.
- Do not use file tools as a substitute for a command execution request.
- When the user asks about changed files, diffs, or git history, use git_status, git_diff, or git_log instead of shell_run.
- Do not start interactive or long-running sessions. Prefer bounded, non-interactive local commands with clear completion conditions.
- %s
- Tool paths should normally be relative to the project working directory.
@@ -113,6 +136,7 @@ func AgentInstructions(workingDir string, yolo bool) string {
- When the user asks to inspect, list, search, or read project files, use file_list, file_search, and file_read instead of saying you cannot access local files.
- When the user asks about code structure, symbols, functions, methods, types, or definitions in Go files, use code_symbols before reading whole files.
- Shell command execution is disabled in this mode. When the user asks to run, execute, test, check, debug, or inspect a shell command, do not use file tools as a substitute; explain that they need to restart agentu with --yolo to enable shell_run.
- When the user asks about changed files, diffs, or git history, use git_status, git_diff, or git_log instead of shell_run.
- %s
- Tool paths should normally be relative to the project working directory.
- Available tools: %s.`, workingDir, searchInstructions, readOnlyTools)
+22 -6
View File
@@ -104,12 +104,12 @@ func TestReadOnlyBuiltinsExposeOpenAICompatibleNamesAndAliases(t *testing.T) {
}
}
for _, want := range []string{"file_list", "file_read", "file_search", "code_symbols"} {
for _, want := range []string{"file_list", "file_read", "file_search", "code_symbols", "git_status", "git_diff", "git_log"} {
if !slices.Contains(names, want) {
t.Fatalf("definitions missing %s: %#v", want, names)
}
}
for _, alias := range []string{"file.list", "file.read", "file.search", "code.symbols"} {
for _, alias := range []string{"file.list", "file.read", "file.search", "code.symbols", "git.status", "git.diff", "git.log"} {
if _, ok := registry.Get(alias); !ok {
t.Fatalf("alias missing: %s", alias)
}
@@ -117,6 +117,9 @@ func TestReadOnlyBuiltinsExposeOpenAICompatibleNamesAndAliases(t *testing.T) {
if _, ok := registry.Get("file_write"); ok {
t.Fatal("read-only registry should not expose file_write")
}
if _, ok := registry.Get("file_edit"); ok {
t.Fatal("read-only registry should not expose file_edit")
}
}
func TestCodeSymbolsToolListsGoSymbols(t *testing.T) {
@@ -170,7 +173,7 @@ func TestRegistryCanExposeWebSearch(t *testing.T) {
func TestBuiltinsExposeYoloTools(t *testing.T) {
registry := Builtins(t.TempDir())
for _, name := range []string{"file_write", "shell_run", "file.write", "shell.run"} {
for _, name := range []string{"file_write", "file_edit", "shell_run", "file.write", "file.edit", "shell.run"} {
if _, ok := registry.Get(name); !ok {
t.Fatalf("tool missing: %s", name)
}
@@ -179,14 +182,14 @@ func TestBuiltinsExposeYoloTools(t *testing.T) {
func TestAgentInstructionsExplainCommandModes(t *testing.T) {
readOnly := AgentInstructions("/tmp/project", false)
for _, want := range []string{"Shell command execution is disabled", "--yolo", "Do not call tools for greetings", "Do not explore the project preemptively", "Do not call file_list just to discover context"} {
for _, want := range []string{"Shell command execution is disabled", "--yolo", "Do not call tools for greetings", "Do not explore the project preemptively", "Do not call file_list just to discover context", "git_status, git_diff, git_log"} {
if !strings.Contains(readOnly, want) {
t.Fatalf("read-only instructions missing %q:\n%s", want, readOnly)
}
}
yolo := AgentInstructions("/tmp/project", true)
for _, want := range []string{"use shell_run", "commands written in backticks", "Do not start interactive", "non-interactive local commands", "Do not call tools for greetings"} {
for _, want := range []string{"use shell_run", "commands written in backticks", "Do not start interactive", "non-interactive local commands", "Do not call tools for greetings", "prefer file_edit"} {
if !strings.Contains(yolo, want) {
t.Fatalf("yolo instructions missing %q:\n%s", want, yolo)
}
@@ -195,7 +198,7 @@ func TestAgentInstructionsExplainCommandModes(t *testing.T) {
func TestAgentInstructionsExplainWebSearch(t *testing.T) {
withSearch := AgentInstructions("/tmp/project", false)
for _, want := range []string{"use web_search", "Use web_fetch", "latest information", "source URLs", "Available tools: file_list, file_read, file_search, code_symbols, web_search, web_fetch"} {
for _, want := range []string{"use web_search", "Use web_fetch", "latest information", "source URLs", "Available tools: file_list, file_read, file_search, code_symbols, git_status, git_diff, git_log, web_search, web_fetch"} {
if !strings.Contains(withSearch, want) {
t.Fatalf("web search instructions missing %q:\n%s", want, withSearch)
}
@@ -257,3 +260,16 @@ func TestResolvePathRejectsDotDotThatEscapes(t *testing.T) {
t.Fatal("expected error for path escaping via ..")
}
}
func TestIsMutating(t *testing.T) {
dir := t.TempDir()
if IsMutating(NewFileReadTool(dir)) {
t.Fatal("FileReadTool should not be mutating")
}
if !IsMutating(NewFileWriteTool(dir)) {
t.Fatal("FileWriteTool should be mutating")
}
if !IsMutating(NewShellRunTool(dir)) {
t.Fatal("ShellRunTool should be mutating")
}
}
+726 -69
View File
File diff suppressed because it is too large Load Diff
+425 -30
View File
@@ -21,7 +21,7 @@ func TestModelRendersChatShell(t *testing.T) {
m = next.(model)
rendered := m.View()
for _, want := range []string{"test-model", "yolo tools", "light", "ctrl+j", "alt+enter"} {
for _, want := range []string{"test-model"} {
if !strings.Contains(rendered, want) {
t.Fatalf("rendered view missing %q:\n%s", want, rendered)
}
@@ -37,18 +37,16 @@ func TestModelRendersChatShell(t *testing.T) {
}
}
func TestInputTextDoesNotPaintBackground(t *testing.T) {
func TestInputTextBackgroundMatchesInputBox(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
for name, color := range map[string]any{
"input box": m.styles.InputBox.GetBackground(),
want := lightTheme().InputBg
for name, color := range map[string]lipgloss.TerminalColor{
"focused base": m.input.FocusedStyle.Base.GetBackground(),
"focused cursor line": m.input.FocusedStyle.CursorLine.GetBackground(),
"blurred base": m.input.BlurredStyle.Base.GetBackground(),
"blurred cursor line": m.input.BlurredStyle.CursorLine.GetBackground(),
} {
if _, ok := color.(lipgloss.NoColor); !ok {
t.Fatalf("%s should not paint input text background", name)
}
requireStyleColor(t, name, color, want)
}
}
@@ -58,12 +56,12 @@ func TestInputBoxIsRoomier(t *testing.T) {
m = next.(model)
if got := m.styles.InputBox.GetVerticalFrameSize(); got != 2 {
t.Fatalf("input box vertical frame should stay compact, got %d", got)
t.Fatalf("input box vertical frame should be 2 with vertical padding, got %d", got)
}
if got := m.styles.InputBox.GetHorizontalFrameSize(); got < 6 {
t.Fatalf("input box horizontal frame should include roomier padding, got %d", got)
if got := m.styles.InputBox.GetHorizontalFrameSize(); got != 3 {
t.Fatalf("input box horizontal frame should be 3 (asymmetric padding), got %d", got)
}
if got, want := m.input.Width(), 100-m.styles.InputBox.GetHorizontalFrameSize(); got != want {
if got, want := m.input.Width(), 100-m.styles.InputBox.GetHorizontalFrameSize()-m.inputModeWidth()-1; got != want {
t.Fatalf("input content width = %d, want %d", got, want)
}
rendered := m.inputView()
@@ -78,13 +76,8 @@ func TestModelMetaRendersBelowInput(t *testing.T) {
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
rendered := next.(model).View()
inputIndex := strings.Index(rendered, "Message agentu")
modelIndex := strings.Index(rendered, "test-model")
if inputIndex < 0 || modelIndex < 0 {
t.Fatalf("rendered view missing input or model meta:\n%s", rendered)
}
if modelIndex < inputIndex {
t.Fatalf("model meta should render below input:\n%s", rendered)
if !strings.Contains(rendered, "test-model") {
t.Fatalf("rendered view missing %q:\n%s", "test-model", rendered)
}
}
@@ -93,7 +86,7 @@ func TestModelMetaRendersContextUsage(t *testing.T) {
assistant.SetMessages([]llm.Message{{Role: llm.RoleUser, Content: strings.Repeat("x", 800)}})
m := newModel(context.Background(), assistant, Options{ModelName: "test-model"})
next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
rendered := next.(model).modelMetaView()
rendered := next.(model).footerView()
for _, want := range []string{"ctx ~", "/1k", "%"} {
if !strings.Contains(rendered, want) {
@@ -123,11 +116,14 @@ func TestRunningStateRendersActivityIndicator(t *testing.T) {
m.resize()
rendered := m.View()
for _, want := range []string{"Working", "thinking...", "esc cancels"} {
for _, want := range []string{iconWorking, "Working", "thinking...", "esc cancels"} {
if !strings.Contains(rendered, want) {
t.Fatalf("running view missing %q:\n%s", want, rendered)
}
}
if backgroundPattern.MatchString(m.activityView()) {
t.Fatalf("activity view should not paint a background:\n%q", m.activityView())
}
}
func TestUserMessageRendersFullWidthBubble(t *testing.T) {
@@ -276,19 +272,127 @@ func TestInputStartsAtOneLineAndGrows(t *testing.T) {
}
}
func TestSlashCommandSuggestions(t *testing.T) {
func TestCommandPaletteFuzzyMatches(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
m.input.SetValue("/")
m.input.SetValue("/mdl")
m.afterInputChanged()
rendered := m.View()
for _, want := range []string{"/clear reset context", "/compact compress context", "/exit quit", "/model model/provider/thinking"} {
if !strings.Contains(rendered, want) {
t.Fatalf("rendered suggestions missing %q:\n%s", want, rendered)
plain := stripANSI(m.View())
for _, want := range []string{iconCommand + " Commands", "/model model/provider/thinking"} {
if !strings.Contains(plain, want) {
t.Fatalf("rendered palette missing %q:\n%s", want, plain)
}
}
matches := commandMatches("m")
modelIndex := -1
renameIndex := -1
for i, match := range matches {
switch match.command.Name {
case "/model":
modelIndex = i
case "/rename":
renameIndex = i
}
}
if modelIndex < 0 || renameIndex < 0 || modelIndex > renameIndex {
t.Fatalf("/model should sort before /rename for /m query: %#v", matches)
}
}
func TestInputHistoryNavigation(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
m.rememberInput("first")
m.rememberInput("second")
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlP})
m = next.(model)
if got := m.input.Value(); got != "second" {
t.Fatalf("after ctrl+p input = %q", got)
}
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlP})
m = next.(model)
if got := m.input.Value(); got != "first" {
t.Fatalf("after second ctrl+p input = %q", got)
}
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlN})
m = next.(model)
if got := m.input.Value(); got != "second" {
t.Fatalf("after ctrl+n input = %q", got)
}
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlN})
m = next.(model)
if got := m.input.Value(); got != "" {
t.Fatalf("after second ctrl+n input = %q", got)
}
}
func TestUpArrowRecallsPreviousInput(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
m.rememberInput("first")
m.rememberInput("second")
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp})
m = next.(model)
if got := m.input.Value(); got != "second" {
t.Fatalf("after first up input = %q, want %q", got, "second")
}
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp})
m = next.(model)
if got := m.input.Value(); got != "first" {
t.Fatalf("after second up input = %q, want %q", got, "first")
}
}
func TestToolMessageRendersStructuredCard(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
m.messages = append(m.messages, message{role: roleTool, content: `file_read {"path":"README.md"}`})
plain := stripANSI(m.renderMessages())
for _, want := range []string{iconTool + " file_read", "README.md"} {
if !strings.Contains(plain, want) {
t.Fatalf("tool card missing %q:\n%s", want, plain)
}
}
}
func TestToolMessageRendersOutputPreview(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
m.messages = append(m.messages, message{role: roleTool, content: "file_read {\"path\":\"README.md\"}\n[output]\nfirst line\nsecond line"})
plain := stripANSI(m.renderMessages())
for _, want := range []string{iconTool + " file_read", "README.md", "output", "first line", "second line"} {
if !strings.Contains(plain, want) {
t.Fatalf("tool card missing %q:\n%s", want, plain)
}
}
}
func TestToolLogOutputUpdatesExistingToolCard(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
m.messages = append(m.messages, message{role: roleTool, content: `file_read {"path":"README.md"}`})
updated := m.mergeToolLog("file_read {\"path\":\"README.md\"}\n[output]\nhello")
if !updated {
t.Fatal("expected mergeToolLog to return true")
}
if len(m.messages) != 1 {
t.Fatalf("messages count = %d, want 1", len(m.messages))
}
plain := stripANSI(m.renderMessages())
if !strings.Contains(plain, "hello") {
t.Fatalf("rendered output missing 'hello':\n%s", plain)
}
status := toolStatusText("file_read {\"path\":\"README.md\"}\n[output]\nhello world")
if strings.Contains(status, "hello") {
t.Fatalf("status should not contain output: %q", status)
}
if !strings.Contains(status, "file_read") {
t.Fatalf("status missing tool name: %q", status)
}
}
type tuiCompactProvider struct{}
@@ -386,16 +490,60 @@ func runInDir(t *testing.T, dir string, name string, args ...string) {
}
}
func TestBangShellCommandRunsFromInput(t *testing.T) {
dir := t.TempDir()
m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true, WorkingDir: dir})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
m.input.SetValue("! printf hello")
next, _ = m.submit()
m = next.(model)
if m.input.Value() != "" {
t.Fatalf("input not cleared after bang command: %q", m.input.Value())
}
rendered := stripANSI(m.renderMessages())
if !strings.Contains(rendered, "$ printf hello") {
t.Fatalf("rendered output missing shell echo:\n%s", rendered)
}
if !strings.Contains(rendered, "hello") {
t.Fatalf("rendered output missing 'hello':\n%s", rendered)
}
}
func TestBangShellCommandRequiresYolo(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: false})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
m.input.SetValue("! printf hello")
next, _ = m.submit()
m = next.(model)
rendered := stripANSI(m.renderMessages())
if !strings.Contains(rendered, "shell command input requires --yolo") {
t.Fatalf("rendered output missing yolo requirement:\n%s", rendered)
}
}
func TestBangShellCommandRejectsEmptyCommand(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
m.input.SetValue("!")
next, _ = m.submit()
m = next.(model)
rendered := stripANSI(m.renderMessages())
if !strings.Contains(rendered, "usage: ! <command>") {
t.Fatalf("rendered output missing usage hint:\n%s", rendered)
}
}
func TestModelCommand(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true})
m.input.SetValue("/model")
next, _ := m.submit()
rendered := next.(model).renderMessages()
for _, want := range []string{"model: test-model", "mode: yolo tools", "theme: light"} {
if !strings.Contains(rendered, want) {
t.Fatalf("model command output missing %q:\n%s", want, rendered)
}
if !strings.Contains(rendered, "test-model") {
t.Fatalf("model command output missing %q:\n%s", "test-model", rendered)
}
}
@@ -414,11 +562,124 @@ func TestModelThinkingCommand(t *testing.T) {
}
}
func TestModelCommandPaletteShowsConfiguredModels(t *testing.T) {
manager := &fakeModelManager{model: "model-a", models: []string{"model-a", "model-b"}}
m := newModel(context.Background(), nil, Options{ModelName: "model-a", ModelManager: manager})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
m.input.SetValue("/model ")
m.afterInputChanged()
plain := stripANSI(m.View())
for _, want := range []string{iconCommand + " Model candidates", "model-a", "current model", "model-b", "switch model"} {
if !strings.Contains(plain, want) {
t.Fatalf("rendered palette missing %q:\n%s", want, plain)
}
}
}
func TestModelCommandPaletteSelectsConfiguredModel(t *testing.T) {
manager := &fakeModelManager{model: "model-a", models: []string{"model-a", "model-b"}}
m := newModel(context.Background(), nil, Options{ModelName: "model-a", ModelManager: manager})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
m.input.SetValue("/model ")
m.afterInputChanged()
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown})
m = next.(model)
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = next.(model)
if manager.model != "model-b" {
t.Fatalf("model = %q", manager.model)
}
if got := m.input.Value(); got != "" {
t.Fatalf("input = %q", got)
}
rendered := stripANSI(m.renderMessages())
if !strings.Contains(rendered, "model: model-b") {
t.Fatalf("rendered output missing model switch:\n%s", rendered)
}
}
func TestModelCommandPaletteFiltersBareModelPartial(t *testing.T) {
manager := &fakeModelManager{model: "claude-sonnet", models: []string{"claude-sonnet", "deepseek-v4-flash"}}
m := newModel(context.Background(), nil, Options{ModelName: "claude-sonnet", ModelManager: manager})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
m.input.SetValue("/model deep")
m.afterInputChanged()
palette := stripANSI(m.commandPaletteView())
if !strings.Contains(palette, "deepseek-v4-flash") {
t.Fatalf("expected deepseek-v4-flash in palette:\n%s", palette)
}
if strings.Contains(palette, "claude-sonnet") {
t.Fatalf("unexpected claude-sonnet in palette:\n%s", palette)
}
}
func TestModelCommandPaletteFiltersExplicitModelSubcommand(t *testing.T) {
manager := &fakeModelManager{model: "claude-sonnet", models: []string{"claude-sonnet", "deepseek-v4-flash"}}
m := newModel(context.Background(), nil, Options{ModelName: "claude-sonnet", ModelManager: manager})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
m.input.SetValue("/model model deep")
m.afterInputChanged()
palette := stripANSI(m.commandPaletteView())
if !strings.Contains(palette, "deepseek-v4-flash") {
t.Fatalf("expected deepseek-v4-flash in palette:\n%s", palette)
}
if strings.Contains(palette, "claude-sonnet") {
t.Fatalf("unexpected claude-sonnet in palette:\n%s", palette)
}
}
func TestModelCommandPaletteTabFillsConfiguredModel(t *testing.T) {
manager := &fakeModelManager{model: "claude-sonnet", models: []string{"claude-sonnet", "deepseek-v4-flash"}}
m := newModel(context.Background(), nil, Options{ModelName: "claude-sonnet", ModelManager: manager})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
m.input.SetValue("/model deep")
m.afterInputChanged()
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyTab})
m = next.(model)
if got := m.input.Value(); got != "/model model deepseek-v4-flash" {
t.Fatalf("input = %q", got)
}
if manager.model != "claude-sonnet" {
t.Fatalf("model should not change on tab: %q", manager.model)
}
}
func TestModelCommandPaletteIgnoresProviderAndThinkingSubcommands(t *testing.T) {
manager := &fakeModelManager{model: "model-a", models: []string{"model-a", "model-b"}}
m := newModel(context.Background(), nil, Options{ModelName: "model-a", ModelManager: manager})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
m.input.SetValue("/model provider ")
m.afterInputChanged()
if sugs := m.commandSuggestions(); len(sugs) != 0 {
t.Fatalf("provider suggestions should be empty, got %d", len(sugs))
}
if view := m.commandPaletteView(); view != "" {
t.Fatalf("provider palette should be empty:\n%s", view)
}
m.input.SetValue("/model thinking ")
m.afterInputChanged()
if sugs := m.commandSuggestions(); len(sugs) != 0 {
t.Fatalf("thinking suggestions should be empty, got %d", len(sugs))
}
if view := m.commandPaletteView(); view != "" {
t.Fatalf("thinking palette should be empty:\n%s", view)
}
}
type fakeModelManager struct {
provider string
model string
thinking string
sessionName string
models []string
saved bool
}
@@ -489,6 +750,16 @@ func (f *fakeModelManager) NewSession() (string, error) {
return "New session.", nil
}
func (f *fakeModelManager) AvailableModels() []string {
if len(f.models) > 0 {
return append([]string(nil), f.models...)
}
if f.model != "" {
return []string{f.model}
}
return nil
}
func TestParseThemeMode(t *testing.T) {
for input, want := range map[string]ThemeMode{
"": ThemeLight,
@@ -510,6 +781,130 @@ func TestParseThemeMode(t *testing.T) {
}
}
func requireStyleColor(t *testing.T, name string, got lipgloss.TerminalColor, want string) {
t.Helper()
color, ok := got.(lipgloss.Color)
if !ok {
t.Fatalf("%s = %T, want lipgloss.Color", name, got)
}
if string(color) != want {
t.Fatalf("%s = %q, want %q", name, string(color), want)
}
}
func requireNoStyleColor(t *testing.T, name string, got lipgloss.TerminalColor) {
t.Helper()
if _, ok := got.(lipgloss.NoColor); !ok {
t.Fatalf("%s should not paint a color, got %T", name, got)
}
}
func TestDarkThemePaintsAppBackground(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model", ThemeMode: ThemeDark})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
rendered := stripANSI(m.View())
_ = rendered
if m.theme.Mode != ThemeDark {
t.Fatalf("theme.Mode = %q, want %q", m.theme.Mode, ThemeDark)
}
want := darkTheme()
requireStyleColor(t, "App.Background", m.styles.App.GetBackground(), want.Background)
requireStyleColor(t, "App.Foreground", m.styles.App.GetForeground(), want.Text)
}
func TestDarkThemeKeepsComponentBackgroundsLightweight(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model", ThemeMode: ThemeDark})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
requireNoStyleColor(t, "Viewport.Background", m.styles.Viewport.GetBackground())
requireStyleColor(t, "InputBox.Background", m.styles.InputBox.GetBackground(), darkTheme().InputBg)
requireNoStyleColor(t, "Activity.Background", m.styles.Activity.GetBackground())
m.running = true
m.status = "thinking..."
m.resize()
if backgroundPattern.MatchString(m.activityView()) {
t.Fatalf("activity view should not paint a background:\n%q", m.activityView())
}
}
func TestDarkThemeStylesUseDarkPalette(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model", ThemeMode: ThemeDark})
want := darkTheme()
requireStyleColor(t, "Header.Foreground", m.styles.Header.GetForeground(), want.Title)
requireStyleColor(t, "Muted.Foreground", m.styles.Muted.GetForeground(), want.Muted)
requireStyleColor(t, "Pill.Background", m.styles.Pill.GetBackground(), want.SurfaceAlt)
requireStyleColor(t, "input.FocusedStyle.Base.Foreground", m.input.FocusedStyle.Base.GetForeground(), want.Text)
requireStyleColor(t, "input.FocusedStyle.Placeholder.Foreground", m.input.FocusedStyle.Placeholder.GetForeground(), want.Muted)
requireStyleColor(t, "InputModeYolo.Foreground", m.styles.InputModeYolo.GetForeground(), want.Error)
requireStyleColor(t, "InputModeCommand.Foreground", m.styles.InputModeCommand.GetForeground(), want.User)
}
func TestInputModeSymbolReflectsYoloAndBangCommand(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
rendered := stripANSI(m.inputView())
if !strings.Contains(rendered, "Y") {
t.Fatalf("YOLO input view should contain Y symbol:\n%s", rendered)
}
m.input.SetValue("! pwd")
m.afterInputChanged()
rendered = stripANSI(m.inputView())
if !strings.Contains(rendered, iconCommand) {
t.Fatalf("bang input view should contain command icon:\n%s", rendered)
}
if strings.Contains(rendered, "Y") {
t.Fatalf("bang input view should not contain Y symbol:\n%s", rendered)
}
}
func TestInputModeYoloUsesWarningColor(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true})
requireStyleColor(t, "InputModeYolo.Foreground", m.styles.InputModeYolo.GetForeground(), lightTheme().Error)
}
func TestThemeSlashCommandSwitchesAtRuntime(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
if m.theme.Mode != ThemeLight {
t.Fatalf("initial theme = %q, want light", m.theme.Mode)
}
requireNoStyleColor(t, "initial App.Background", m.styles.App.GetBackground())
// Switch to dark
m.input.SetValue("/theme dark")
next, _ := m.submit()
m = next.(model)
if m.theme.Mode != ThemeDark {
t.Fatalf("after /theme dark: Mode = %q, want dark", m.theme.Mode)
}
requireStyleColor(t, "dark App.Background", m.styles.App.GetBackground(), darkTheme().Background)
last := m.messages[len(m.messages)-1]
if !strings.Contains(last.content, "theme: dark") {
t.Fatalf("expected confirmation message, got %q", last.content)
}
// Switch back to light
m.input.SetValue("/theme light")
next, _ = m.submit()
m = next.(model)
if m.theme.Mode != ThemeLight {
t.Fatalf("after /theme light: Mode = %q, want light", m.theme.Mode)
}
requireNoStyleColor(t, "restored App.Background", m.styles.App.GetBackground())
// No arg shows usage
m.input.SetValue("/theme")
next, _ = m.submit()
m = next.(model)
last = m.messages[len(m.messages)-1]
if !strings.Contains(last.content, "usage: /theme") {
t.Fatalf("expected usage hint, got %q", last.content)
}
}
func TestCleanToolLog(t *testing.T) {
got := cleanToolLog("\n[tool] shell_run {\"command\":\"pwd\"}\n")
want := `shell_run {"command":"pwd"}`
+110 -19
View File
@@ -15,6 +15,17 @@ const (
ThemeDark ThemeMode = "dark"
)
const (
iconReady = "✓"
iconWorking = "◆"
iconTool = "▣"
iconError = "✕"
iconCommand = "⌘"
iconSession = "◌"
iconContext = "◔"
iconModel = "◉"
)
func ParseThemeMode(value string) (ThemeMode, error) {
switch ThemeMode(strings.ToLower(strings.TrimSpace(value))) {
case "", ThemeLight:
@@ -32,6 +43,7 @@ type theme struct {
Background string
Surface string
SurfaceAlt string
InputBg string
Border string
Text string
Muted string
@@ -48,11 +60,25 @@ type styles struct {
App lipgloss.Style
Muted lipgloss.Style
Activity lipgloss.Style
ModelMeta lipgloss.Style
Header lipgloss.Style
Footer lipgloss.Style
Viewport lipgloss.Style
InputBox lipgloss.Style
CommandHint lipgloss.Style
InputModeReadonly lipgloss.Style
InputModeYolo lipgloss.Style
InputModeCommand lipgloss.Style
StatusReady lipgloss.Style
StatusWarn lipgloss.Style
StatusError lipgloss.Style
StatusInfo lipgloss.Style
Pill lipgloss.Style
PillAccent lipgloss.Style
Palette lipgloss.Style
PaletteMatch lipgloss.Style
ToolName lipgloss.Style
ToolKey lipgloss.Style
ToolValue lipgloss.Style
UserLabel lipgloss.Style
ToolLabel lipgloss.Style
ErrorLabel lipgloss.Style
@@ -75,8 +101,8 @@ func lightTheme() theme {
return theme{
Mode: ThemeLight,
Background: "#F8FAFC",
Surface: "#FFFFFF",
SurfaceAlt: "#EEF2F7",
InputBg: "#E4E4E4",
Border: "#CBD5E1",
Text: "#111827",
Muted: "#64748B",
@@ -93,8 +119,8 @@ func darkTheme() theme {
return theme{
Mode: ThemeDark,
Background: "#111827",
Surface: "#1F2937",
SurfaceAlt: "#0F172A",
InputBg: "#383838",
Border: "#475569",
Text: "#E5E7EB",
Muted: "#94A3B8",
@@ -107,24 +133,28 @@ func darkTheme() theme {
}
}
func appStyle(t theme) lipgloss.Style {
style := lipgloss.NewStyle().Foreground(lipgloss.Color(t.Text))
if t.Mode == ThemeDark {
style = style.Background(lipgloss.Color(t.Background))
}
return style
}
func (t theme) styles() styles {
return styles{
App: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Text)),
App: appStyle(t),
Muted: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)),
Activity: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(t.Background)).
Background(lipgloss.Color(t.Title)).
Foreground(lipgloss.Color(t.Muted)).
Padding(0, 1),
ModelMeta: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Text)).
Background(lipgloss.Color(t.SurfaceAlt)).
Padding(0, 1),
Header: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(t.Title)),
Footer: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)).
@@ -134,15 +164,74 @@ func (t theme) styles() styles {
Foreground(lipgloss.Color(t.Text)),
InputBox: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Text)).
Background(lipgloss.Color(t.InputBg)).
Padding(1, 2, 1, 1),
CommandHint: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)).
Padding(0, 1),
InputModeReadonly: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(t.Muted)).
Background(lipgloss.Color(t.InputBg)).
Padding(0, 1),
InputModeYolo: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(t.Error)).
Background(lipgloss.Color(t.InputBg)).
Padding(0, 1),
InputModeCommand: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(t.User)).
Background(lipgloss.Color(t.InputBg)).
Padding(0, 1),
StatusReady: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Assistant)),
StatusWarn: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Tool)),
StatusError: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Error)),
StatusInfo: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.User)),
Pill: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Text)).
Background(lipgloss.Color(t.SurfaceAlt)).
Padding(0, 1),
PillAccent: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(t.Title)).
Background(lipgloss.Color(t.SurfaceAlt)).
Padding(0, 1),
Palette: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Text)).
Border(lipgloss.ThickBorder()).
BorderForeground(lipgloss.Color(t.Border)).
Padding(0, 2),
CommandHint: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)).
Background(lipgloss.Color(t.SurfaceAlt)).
Padding(0, 1),
PaletteMatch: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(t.User)),
ToolName: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(t.Tool)),
ToolKey: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)),
ToolValue: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Text)),
UserLabel: labelStyle(t.User),
ToolLabel: labelStyle(t.Tool),
@@ -185,8 +274,10 @@ func bubbleMessageStyle(textColor, backgroundColor string) lipgloss.Style {
func applyTextareaTheme(input *textarea.Model, t theme) {
focused := textarea.Style{
Base: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Text)),
CursorLine: lipgloss.NewStyle(),
Foreground(lipgloss.Color(t.Text)).
Background(lipgloss.Color(t.InputBg)),
CursorLine: lipgloss.NewStyle().
Background(lipgloss.Color(t.InputBg)),
Placeholder: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)),
Prompt: lipgloss.NewStyle().
@@ -197,7 +288,7 @@ func applyTextareaTheme(input *textarea.Model, t theme) {
Foreground(lipgloss.Color(t.Surface)),
}
blurred := focused
blurred.CursorLine = lipgloss.NewStyle()
blurred.CursorLine = lipgloss.NewStyle().Background(lipgloss.Color(t.InputBg))
input.FocusedStyle = focused
input.BlurredStyle = blurred
+2 -209
View File
@@ -1,222 +1,15 @@
package main
import (
"bufio"
"context"
"errors"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"agentu/internal/agent"
"agentu/pkg/config"
"agentu/pkg/llm"
"agentu/internal/session"
"agentu/internal/tools"
"agentu/internal/tui"
"agentu/internal/app"
)
const defaultConfigContent = `# agentu configuration
# Replace the placeholder values below with your actual provider settings.
# See agentu.example.yaml for the full reference.
providers:
default:
base_url: https://api.openai.com
api_key: YOUR_API_KEY_HERE
models:
- id: gpt-4o
context: 128000
- id: gpt-4o-mini
context: 128000
`
func main() {
if err := run(); err != nil {
if err := app.Run(); err != nil {
fmt.Fprintln(os.Stderr, "agentu:", err)
os.Exit(1)
}
}
func run() error {
configPath := flag.String("config", config.DefaultPath, "path to YAML config")
yolo := flag.Bool("yolo", false, "also enable automatic file writes and shell execution")
plain := flag.Bool("plain", false, "use the simple line-based REPL instead of the TUI")
themeName := flag.String("theme", "light", "TUI theme: light or dark")
resumeID := flag.String("resume", "", "resume a specific session by ID")
flag.Parse()
themeMode, err := tui.ParseThemeMode(*themeName)
if err != nil {
return err
}
cfg, err := config.Load(*configPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
if bootstrapErr := bootstrapConfig(*configPath); bootstrapErr != nil {
return bootstrapErr
}
return fmt.Errorf("created default config at %s; edit it to set your API key and provider, then restart agentu", *configPath)
}
return err
}
providerName := cfg.DefaultProviderName()
providerConfig := cfg.Providers[providerName]
provider := llm.NewOpenAICompatibleClient(providerConfig.BaseURL, providerConfig.APIKey, http.DefaultClient)
var registry *tools.Registry
if *yolo {
registry = tools.Builtins(cfg.Agent.WorkingDir)
} else {
registry = tools.ReadOnlyBuiltins(cfg.Agent.WorkingDir)
}
registry.Register(tools.NewBuiltinSearchTool(http.DefaultClient))
registry.Register(tools.NewBuiltinFetchTool(http.DefaultClient))
systemPrompt := strings.TrimSpace(cfg.Agent.SystemPrompt + "\n\n" + tools.AgentInstructions(cfg.Agent.WorkingDir, *yolo))
maxContextTokens := cfg.Agent.MaxContextTokens
if maxContextTokens == 0 {
maxContextTokens = providerConfig.ContextTokens
}
assistant := agent.New(agent.Options{
Provider: provider,
ProviderName: providerName,
Model: providerConfig.Model,
Thinking: providerConfig.Thinking,
ThinkingParam: providerConfig.ThinkingParam,
ThinkingEnabled: providerConfig.ThinkingConfigured,
SystemPrompt: systemPrompt,
ToolRegistry: registry,
ToolTimeout: cfg.Agent.ToolTimeout,
MaxContextTokens: maxContextTokens,
})
// Initialize session store
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("resolve home dir: %w", err)
}
sessionsDir := filepath.Join(home, ".agentu", "sessions")
store, err := session.NewStore(sessionsDir)
if err != nil {
return fmt.Errorf("init session store: %w", err)
}
modelManager, err := session.NewManager(cfg, assistant, http.DefaultClient, store)
if err != nil {
return err
}
// Resume session
if *resumeID != "" {
if err := modelManager.Resume(*resumeID); err != nil {
return fmt.Errorf("resume session: %w", err)
}
} else {
if err := modelManager.ResumeLatest(); err != nil {
return fmt.Errorf("resume latest session: %w", err)
}
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
if !*plain {
err := tui.Run(ctx, assistant, tui.Options{
ModelName: providerConfig.Model,
Yolo: *yolo,
ThemeMode: themeMode,
ModelManager: modelManager,
WorkingDir: cfg.Agent.WorkingDir,
})
printExitSession(modelManager)
return err
}
return repl(ctx, assistant, modelManager)
}
func bootstrapConfig(configPath string) error {
resolved, err := config.ResolvePath(configPath)
if err != nil {
return fmt.Errorf("resolve config path: %w", err)
}
dir := filepath.Dir(resolved)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create config directory: %w", err)
}
if err := os.WriteFile(resolved, []byte(defaultConfigContent), 0o644); err != nil {
return fmt.Errorf("write default config: %w", err)
}
return nil
}
func repl(ctx context.Context, assistant *agent.Agent, modelManager *session.Manager) error {
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
fmt.Println("agentu")
fmt.Println("Type /exit to quit, /clear to reset context, /compact to compress context.")
for {
fmt.Print("> ")
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return err
}
fmt.Println()
printExitSession(modelManager)
return nil
}
input := strings.TrimSpace(scanner.Text())
switch input {
case "":
continue
case "/exit", "/quit":
printExitSession(modelManager)
return nil
case "/clear":
assistant.Clear()
fmt.Println("context cleared")
continue
case "/compact":
if err := assistant.Compact(ctx); err != nil {
fmt.Fprintln(os.Stderr, "compact error:", err)
} else {
fmt.Println("context compacted")
if modelManager != nil {
_ = modelManager.Save()
}
}
continue
}
if err := assistant.RunTurn(ctx, input, os.Stdout, os.Stderr); err != nil {
fmt.Fprintln(os.Stderr, "\nerror:", err)
}
_ = modelManager.Save()
fmt.Println()
}
}
func printExitSession(m *session.Manager) {
if m == nil {
return
}
_ = m.Save()
id := m.CurrentSessionID()
name := m.CurrentSessionName()
if id != "" {
if name != "" && name != id {
fmt.Fprintf(os.Stderr, "session: %s (%s)\n", id, name)
} else {
fmt.Fprintf(os.Stderr, "session: %s\n", id)
}
fmt.Fprintf(os.Stderr, "resume: agentu --resume %s\n", id)
}
}