2 Commits

Author SHA1 Message Date
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
16 changed files with 1676 additions and 110 deletions
+8 -2
View File
@@ -125,6 +125,9 @@ Read-only tools are available by default:
- `file_list` - `file_list`
- `file_search` with optional `context_lines` and `max_results` - `file_search` with optional `context_lines` and `max_results`
- `code_symbols` for Go packages, imports, types, functions, and methods - `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: Web tools are also available in read-only mode:
@@ -133,8 +136,9 @@ Web tools are also available in read-only mode:
`--yolo` additionally enables: `--yolo` additionally enables:
- `file_write` - `file_write` for creating or replacing whole files
- `shell_run` - `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 Use `--yolo` for prompts that ask agentu to run or inspect local commands, for
example: example:
@@ -143,6 +147,8 @@ example:
请帮我运行 `go test ./...` 并总结结果 请帮我运行 `go test ./...` 并总结结果
``` ```
## Development ## Development
Run the verification suite: Run the verification suite:
+96 -17
View File
@@ -7,18 +7,22 @@ import (
"io" "io"
"sort" "sort"
"strings" "strings"
"sync"
"time" "time"
"agentu/pkg/llm"
"agentu/internal/tools" "agentu/internal/tools"
"agentu/pkg/llm"
) )
const ( const (
defaultMaxToolRounds = 50 defaultMaxToolRounds = 50
doomLoopThreshold = 3 doomLoopThreshold = 3
compactRecentMessages = 6 compactRecentMessages = 6
summaryMessageLimit = 8000 summaryMessageLimit = 8000
contextSummaryPrefix = "[context summary]" 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" + 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" + "- Key facts, decisions, and conclusions\n" +
@@ -29,6 +33,11 @@ const (
"Output only the summary, no preamble." "Output only the summary, no preamble."
) )
type toolExecutionResult struct {
call llm.ToolCall
output string
}
type Agent struct { type Agent struct {
provider llm.Provider provider llm.Provider
providerName string providerName string
@@ -364,14 +373,15 @@ func (a *Agent) RunTurn(ctx context.Context, input string, out io.Writer, logs i
} }
} }
for _, call := range calls { results := a.executeTools(ctx, calls, logs)
result := a.executeTool(ctx, call, logs) for _, result := range results {
a.messages = append(a.messages, llm.Message{ a.messages = append(a.messages, llm.Message{
Role: llm.RoleTool, Role: llm.RoleTool,
ToolCallID: call.ID, ToolCallID: result.call.ID,
Content: result, 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) return fmt.Errorf("tool round limit reached after %d rounds; the task may be too complex for a single turn", a.maxToolRounds)
@@ -433,11 +443,8 @@ func (a *Agent) toolDefinitions() []llm.Tool {
return a.toolRegistry.Definitions() 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 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 { if a.toolRegistry == nil {
return tools.FormatError(fmt.Errorf("tool registry is disabled")) return tools.FormatError(fmt.Errorf("tool registry is disabled"))
@@ -461,12 +468,84 @@ func (a *Agent) executeTool(ctx context.Context, call llm.ToolCall, logs io.Writ
} }
return tools.FormatError(err) return tools.FormatError(err)
} }
if logs != nil {
fmt.Fprintf(logs, "[tool] %s done\n", name)
}
return output 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 { type toolCallBuilder struct {
index int index int
id string id string
+172 -1
View File
@@ -7,8 +7,8 @@ import (
"strings" "strings"
"testing" "testing"
"agentu/pkg/llm"
"agentu/internal/tools" "agentu/internal/tools"
"agentu/pkg/llm"
) )
type fakeProvider struct { type fakeProvider struct {
@@ -340,3 +340,174 @@ func TestAgentHardLimitStopsVaryingCalls(t *testing.T) {
t.Fatalf("calls = %d, want %d", provider.calls, limit) 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})
}
+1
View File
@@ -81,6 +81,7 @@ type FileWriteTool struct {
func NewFileWriteTool(workingDir string) *FileWriteTool { func NewFileWriteTool(workingDir string) *FileWriteTool {
return &FileWriteTool{workingDir: workingDir} return &FileWriteTool{workingDir: workingDir}
} }
func (*FileWriteTool) Mutates() bool { return true }
func (t *FileWriteTool) Definition() llm.Tool { func (t *FileWriteTool) Definition() llm.Tool {
return 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 { func NewShellRunTool(workingDir string) *ShellRunTool {
return &ShellRunTool{workingDir: workingDir} return &ShellRunTool{workingDir: workingDir}
} }
func (*ShellRunTool) Mutates() bool { return true }
func (t *ShellRunTool) Definition() llm.Tool { func (t *ShellRunTool) Definition() llm.Tool {
return 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) 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 { type Registry struct {
tools map[string]Tool tools map[string]Tool
primary []string primary []string
@@ -66,30 +78,39 @@ func ReadOnlyBuiltins(workingDir string) *Registry {
fileList := NewFileListTool(workingDir) fileList := NewFileListTool(workingDir)
fileSearch := NewFileSearchTool(workingDir) fileSearch := NewFileSearchTool(workingDir)
codeSymbols := NewCodeSymbolsTool(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.read", fileRead)
registry.RegisterAlias("file.list", fileList) registry.RegisterAlias("file.list", fileList)
registry.RegisterAlias("file.search", fileSearch) registry.RegisterAlias("file.search", fileSearch)
registry.RegisterAlias("code.symbols", codeSymbols) registry.RegisterAlias("code.symbols", codeSymbols)
registry.RegisterAlias("git.status", gitStatus)
registry.RegisterAlias("git.diff", gitDiff)
registry.RegisterAlias("git.log", gitLog)
return registry return registry
} }
func Builtins(workingDir string) *Registry { func Builtins(workingDir string) *Registry {
registry := ReadOnlyBuiltins(workingDir) registry := ReadOnlyBuiltins(workingDir)
fileWrite := NewFileWriteTool(workingDir) fileWrite := NewFileWriteTool(workingDir)
fileEdit := NewFileEditTool(workingDir)
shellRun := NewShellRunTool(workingDir) shellRun := NewShellRunTool(workingDir)
registry.Register(fileWrite) registry.Register(fileWrite)
registry.Register(fileEdit)
registry.Register(shellRun) registry.Register(shellRun)
registry.RegisterAlias("file.write", fileWrite) registry.RegisterAlias("file.write", fileWrite)
registry.RegisterAlias("file.edit", fileEdit)
registry.RegisterAlias("shell.run", shellRun) registry.RegisterAlias("shell.run", shellRun)
return registry return registry
} }
func AgentInstructions(workingDir string, yolo bool) string { func AgentInstructions(workingDir string, yolo bool) string {
searchInstructions := webSearchInstructions() searchInstructions := webSearchInstructions()
readOnlyTools := "file_list, file_read, file_search, code_symbols, 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, shell_run, 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 { if yolo {
return fmt.Sprintf(`Local project context: return fmt.Sprintf(`Local project context:
- The project working directory is %q. - 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 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 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 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. - 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. - Do not start interactive or long-running sessions. Prefer bounded, non-interactive local commands with clear completion conditions.
- %s - %s
- Tool paths should normally be relative to the project working directory. - 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 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 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. - 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 - %s
- Tool paths should normally be relative to the project working directory. - Tool paths should normally be relative to the project working directory.
- Available tools: %s.`, workingDir, searchInstructions, readOnlyTools) - 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) { if !slices.Contains(names, want) {
t.Fatalf("definitions missing %s: %#v", want, names) 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 { if _, ok := registry.Get(alias); !ok {
t.Fatalf("alias missing: %s", alias) t.Fatalf("alias missing: %s", alias)
} }
@@ -117,6 +117,9 @@ func TestReadOnlyBuiltinsExposeOpenAICompatibleNamesAndAliases(t *testing.T) {
if _, ok := registry.Get("file_write"); ok { if _, ok := registry.Get("file_write"); ok {
t.Fatal("read-only registry should not expose file_write") 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) { func TestCodeSymbolsToolListsGoSymbols(t *testing.T) {
@@ -170,7 +173,7 @@ func TestRegistryCanExposeWebSearch(t *testing.T) {
func TestBuiltinsExposeYoloTools(t *testing.T) { func TestBuiltinsExposeYoloTools(t *testing.T) {
registry := Builtins(t.TempDir()) 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 { if _, ok := registry.Get(name); !ok {
t.Fatalf("tool missing: %s", name) t.Fatalf("tool missing: %s", name)
} }
@@ -179,14 +182,14 @@ func TestBuiltinsExposeYoloTools(t *testing.T) {
func TestAgentInstructionsExplainCommandModes(t *testing.T) { func TestAgentInstructionsExplainCommandModes(t *testing.T) {
readOnly := AgentInstructions("/tmp/project", false) 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) { if !strings.Contains(readOnly, want) {
t.Fatalf("read-only instructions missing %q:\n%s", want, readOnly) t.Fatalf("read-only instructions missing %q:\n%s", want, readOnly)
} }
} }
yolo := AgentInstructions("/tmp/project", true) 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) { if !strings.Contains(yolo, want) {
t.Fatalf("yolo instructions missing %q:\n%s", want, yolo) t.Fatalf("yolo instructions missing %q:\n%s", want, yolo)
} }
@@ -195,7 +198,7 @@ func TestAgentInstructionsExplainCommandModes(t *testing.T) {
func TestAgentInstructionsExplainWebSearch(t *testing.T) { func TestAgentInstructionsExplainWebSearch(t *testing.T) {
withSearch := AgentInstructions("/tmp/project", false) 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) { if !strings.Contains(withSearch, want) {
t.Fatalf("web search instructions missing %q:\n%s", want, withSearch) 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 ..") 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")
}
}
+356 -57
View File
@@ -6,7 +6,9 @@ import (
"fmt" "fmt"
"io" "io"
"math" "math"
"sort"
"strings" "strings"
"unicode"
"agentu/internal/agent" "agentu/internal/agent"
"agentu/pkg/llm" "agentu/pkg/llm"
@@ -19,8 +21,10 @@ import (
) )
const ( const (
minInputLines = 1 minInputLines = 1
maxInputLines = 6 maxInputLines = 6
maxCommandPaletteItems = 8
maxInputHistory = 100
) )
type Options struct { type Options struct {
@@ -89,9 +93,13 @@ type model struct {
messages []message messages []message
status string status string
contextUsage string contextUsage string
running bool inputHistory []string
cancel context.CancelFunc historyIndex int
events <-chan tea.Msg draftInput string
running bool
cancel context.CancelFunc
events <-chan tea.Msg
// Session picker state // Session picker state
picking bool picking bool
@@ -120,6 +128,11 @@ type slashCommand struct {
Description string Description string
} }
type commandMatch struct {
command slashCommand
score int
}
var slashCommands = []slashCommand{ var slashCommands = []slashCommand{
{Name: "/clear", Description: "reset context"}, {Name: "/clear", Description: "reset context"},
{Name: "/compact", Description: "compress context"}, {Name: "/compact", Description: "compress context"},
@@ -162,18 +175,19 @@ func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model {
vp.SetContent("") vp.SetContent("")
m := model{ m := model{
ctx: ctx, ctx: ctx,
agent: assistant, agent: assistant,
modelName: opts.ModelName, modelName: opts.ModelName,
yolo: opts.Yolo, yolo: opts.Yolo,
models: opts.ModelManager, models: opts.ModelManager,
workingDir: opts.WorkingDir, workingDir: opts.WorkingDir,
theme: th, theme: th,
styles: st, styles: st,
viewport: vp, viewport: vp,
input: input, input: input,
spinner: spin, spinner: spin,
status: "ready", status: "ready",
historyIndex: -1,
} }
m.refreshContextUsage() m.refreshContextUsage()
return m return m
@@ -249,6 +263,20 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
return m.submit() return m.submit()
case "ctrl+p":
if m.running {
return m, nil
}
m.previousInputHistory()
m.afterInputChanged()
return m, nil
case "ctrl+n":
if m.running {
return m, nil
}
m.nextInputHistory()
m.afterInputChanged()
return m, nil
case "alt+enter", "ctrl+j": case "alt+enter", "ctrl+j":
return m.insertInputNewline() return m.insertInputNewline()
} }
@@ -262,8 +290,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case toolLogMsg: case toolLogMsg:
text := cleanToolLog(string(msg)) text := cleanToolLog(string(msg))
if text != "" { if text != "" {
m.messages = append(m.messages, message{role: roleTool, content: text}) if !m.mergeToolLog(text) {
m.status = text m.messages = append(m.messages, message{role: roleTool, content: text})
}
m.status = toolStatusText(text)
m.refreshViewport(true) m.refreshViewport(true)
} }
return m, m.waitForEvent() return m, m.waitForEvent()
@@ -297,6 +327,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if !m.running { if !m.running {
nextInput, cmd := m.input.Update(msg) nextInput, cmd := m.input.Update(msg)
m.input = nextInput m.input = nextInput
m.historyIndex = -1
m.draftInput = ""
m.afterInputChanged() m.afterInputChanged()
return m, cmd return m, cmd
} }
@@ -343,6 +375,7 @@ func (m *model) submit() (tea.Model, tea.Cmd) {
if input == "" { if input == "" {
return *m, nil return *m, nil
} }
m.rememberInput(input)
switch input { switch input {
case "/exit", "/quit": case "/exit", "/quit":
@@ -402,6 +435,8 @@ func (m *model) insertInputNewline() (tea.Model, tea.Cmd) {
enter := tea.KeyMsg{Type: tea.KeyEnter} enter := tea.KeyMsg{Type: tea.KeyEnter}
nextInput, cmd := m.input.Update(enter) nextInput, cmd := m.input.Update(enter)
m.input = nextInput m.input = nextInput
m.historyIndex = -1
m.draftInput = ""
m.afterInputChanged() m.afterInputChanged()
return *m, cmd return *m, cmd
} }
@@ -472,6 +507,10 @@ func (m model) renderMessages() string {
} }
func (m model) messageView(r role, content string, width int) string { func (m model) messageView(r role, content string, width int) string {
if r == roleTool {
return m.toolMessageView(content, width)
}
label := roleLabel(r) label := roleLabel(r)
style := m.styles.AssistantMsg style := m.styles.AssistantMsg
labelStyle := m.styles.SystemLabel labelStyle := m.styles.SystemLabel
@@ -534,39 +573,169 @@ func roleLabel(r role) string {
} }
} }
type toolLogParts struct {
name string
args string
output string
}
func parseToolLog(content string) toolLogParts {
content = strings.TrimSpace(content)
if content == "" {
return toolLogParts{}
}
beforeOutput, output, hasOutput := strings.Cut(content, "\n[output]\n")
parts := toolLogParts{}
if hasOutput {
parts.output = strings.TrimSpace(output)
}
firstLine := strings.SplitN(beforeOutput, "\n", 2)[0]
firstLine = strings.TrimSpace(firstLine)
idx := strings.IndexFunc(firstLine, unicode.IsSpace)
if idx < 0 {
parts.name = firstLine
return parts
}
parts.name = strings.TrimSpace(firstLine[:idx])
parts.args = strings.TrimSpace(firstLine[idx+1:])
return parts
}
func (m model) toolMessageView(content string, width int) string {
parts := parseToolLog(content)
header := m.styles.ToolName.Render(strings.TrimSpace(iconTool + " " + parts.name))
lines := []string{header}
if parts.args != "" {
args := fitLine(parts.args, max(1, width-4))
lines = append(lines, m.styles.ToolValue.Render(args))
}
if parts.output != "" {
lines = append(lines, m.styles.ToolKey.Render("output"))
previewLines := toolOutputPreviewLines(parts.output, max(1, width-4))
for _, line := range previewLines {
lines = append(lines, m.styles.ToolValue.Render(line))
}
}
bodyContent := strings.Join(lines, "\n")
bodyWidth := messageBodyWidth(bodyContent, width, m.styles.ToolMessage)
return m.styles.ToolMessage.Width(bodyWidth).Render(bodyContent)
}
const maxToolOutputPreviewLines = 8
func toolStatusText(content string) string {
content = strings.TrimSpace(content)
if content == "" {
return ""
}
lines := strings.SplitN(content, "\n", 2)
return lines[0]
}
func (m *model) mergeToolLog(text string) bool {
incoming := parseToolLog(text)
if incoming.output == "" {
return false
}
for i := len(m.messages) - 1; i >= 0; i-- {
if m.messages[i].role != roleTool {
continue
}
existing := parseToolLog(m.messages[i].content)
if existing.name == incoming.name && existing.args == incoming.args && existing.output == "" {
m.messages[i].content = text
return true
}
}
return false
}
func toolOutputPreviewLines(output string, width int) []string {
lines := strings.Split(output, "\n")
if len(lines) > maxToolOutputPreviewLines {
lines = lines[:maxToolOutputPreviewLines]
lines = append(lines, fmt.Sprintf("[truncated: showing first %d lines]", maxToolOutputPreviewLines))
}
result := make([]string, 0, len(lines))
for _, line := range lines {
result = append(result, fitLine(line, width))
}
return result
}
type metaPill struct {
text string
accent bool
}
func (m model) renderMetaPills(parts []metaPill) string {
if len(parts) == 0 {
return ""
}
plain := make([]string, len(parts))
for i, part := range parts {
plain[i] = part.text
}
width := max(1, m.width-2)
visible := len(plain)
for visible > 1 && lipgloss.Width(strings.Join(plain[:visible], " ")) > width {
visible--
}
if lipgloss.Width(strings.Join(plain[:visible], " ")) > width {
return m.styles.PillAccent.Render(fitLine(plain[0], width))
}
rendered := make([]string, 0, visible)
for i := 0; i < visible; i++ {
style := m.styles.Pill
if parts[i].accent {
style = m.styles.PillAccent
}
rendered = append(rendered, style.Render(parts[i].text))
}
return strings.Join(rendered, " ")
}
func (m model) modelMetaView() string { func (m model) modelMetaView() string {
mode := "read-only tools" mode := "read-only tools"
if m.yolo { if m.yolo {
mode = "yolo tools" mode = "yolo tools"
} }
modelName := m.modelName modelName := m.modelName
parts := []string{}
if m.models != nil { if m.models != nil {
if sessionName := m.models.CurrentSessionName(); sessionName != "" { if currentModel := m.models.CurrentModel(); currentModel != "" {
parts = append(parts, "session "+sessionName) modelName = currentModel
} }
}
parts := []metaPill{{text: iconModel + " " + modelName, accent: true}}
if m.models != nil {
if provider := m.models.CurrentProvider(); provider != "" { if provider := m.models.CurrentProvider(); provider != "" {
parts = append(parts, "provider "+provider) parts = append(parts, metaPill{text: "provider " + provider})
} }
modelName = m.models.CurrentModel()
if currentThinking := m.models.CurrentThinking(); currentThinking != "" && currentThinking != "unset" { if currentThinking := m.models.CurrentThinking(); currentThinking != "" && currentThinking != "unset" {
parts = append(parts, "thinking "+currentThinking) parts = append(parts, metaPill{text: "thinking " + currentThinking})
}
if sessionName := m.models.CurrentSessionName(); sessionName != "" {
parts = append(parts, metaPill{text: iconSession + " " + sessionName})
} }
} }
if m.contextUsage != "" { if m.contextUsage != "" {
parts = append(parts, m.contextUsage) parts = append(parts, metaPill{text: iconContext + " " + m.contextUsage})
} }
parts = append([]string{modelName, mode}, parts...) parts = append(parts, metaPill{text: mode}, metaPill{text: "theme " + string(m.theme.Mode)})
parts = append(parts, "theme "+string(m.theme.Mode))
line := fitLine(strings.Join(parts, " · "), max(1, m.width-2)) return m.styles.ModelMeta.Width(max(1, m.width)).Render(m.renderMetaPills(parts))
return m.styles.ModelMeta.Width(max(1, m.width)).Render(line)
} }
func (m model) inputView() string { func (m model) inputView() string {
suggestions := m.slashSuggestionsView() palette := m.commandPaletteView()
box := m.styles.InputBox.Width(max(20, m.width-2)).Render(m.input.View()) box := m.styles.InputBox.Width(max(20, m.width-2)).Render(m.input.View())
if suggestions != "" { if palette != "" {
return lipgloss.JoinVertical(lipgloss.Left, suggestions, box) return lipgloss.JoinVertical(lipgloss.Left, palette, box)
} }
return box return box
} }
@@ -579,12 +748,12 @@ func (m model) activityView() string {
if status == "" || status == "ready" { if status == "" || status == "ready" {
status = "working..." status = "working..."
} }
line := fitLine(fmt.Sprintf("%s Working · %s · esc cancels", m.spinner.View(), status), max(1, m.width-2)) line := m.styles.StatusInfo.Render(iconWorking) + " Working " + m.spinner.View() + " " + status + " · esc cancels"
return m.styles.Activity.Width(max(1, m.width)).Render(line) return m.styles.Activity.Render(fitLine(line, max(1, m.width-2)))
} }
func (m model) footerView() string { func (m model) footerView() string {
hint := "enter send · ctrl+j newline · alt+enter newline · / commands" hint := "enter send · ctrl+j newline · alt+enter newline · ctrl+p/ctrl+n history · / commands"
if m.running { if m.running {
hint = "esc cancel · ctrl+c quit" hint = "esc cancel · ctrl+c quit"
} }
@@ -639,6 +808,51 @@ func (m *model) afterInputChanged() {
} }
} }
func (m *model) rememberInput(input string) {
input = strings.TrimSpace(input)
if input == "" {
return
}
if len(m.inputHistory) > 0 && m.inputHistory[len(m.inputHistory)-1] == input {
m.historyIndex = -1
m.draftInput = ""
return
}
m.inputHistory = append(m.inputHistory, input)
if len(m.inputHistory) > maxInputHistory {
m.inputHistory = m.inputHistory[len(m.inputHistory)-maxInputHistory:]
}
m.historyIndex = -1
m.draftInput = ""
}
func (m *model) previousInputHistory() {
if len(m.inputHistory) == 0 {
return
}
if m.historyIndex == -1 {
m.draftInput = m.input.Value()
m.historyIndex = len(m.inputHistory) - 1
} else if m.historyIndex > 0 {
m.historyIndex--
}
m.input.SetValue(m.inputHistory[m.historyIndex])
}
func (m *model) nextInputHistory() {
if len(m.inputHistory) == 0 || m.historyIndex == -1 {
return
}
if m.historyIndex < len(m.inputHistory)-1 {
m.historyIndex++
m.input.SetValue(m.inputHistory[m.historyIndex])
return
}
m.input.SetValue(m.draftInput)
m.historyIndex = -1
m.draftInput = ""
}
func (m *model) syncInputHeight() { func (m *model) syncInputHeight() {
m.input.SetHeight(m.desiredInputHeight()) m.input.SetHeight(m.desiredInputHeight())
} }
@@ -661,47 +875,132 @@ func (m model) desiredInputHeight() int {
func (m model) inputBlockHeight() int { func (m model) inputBlockHeight() int {
height := m.input.Height() + m.styles.InputBox.GetVerticalFrameSize() height := m.input.Height() + m.styles.InputBox.GetVerticalFrameSize()
if m.showSlashSuggestions() { if m.showCommandPalette() {
height += m.slashSuggestionCount() height += m.commandPaletteHeight()
} }
return height return height
} }
func (m model) slashSuggestionCount() int { func (m model) commandPaletteHeight() int {
prefix := strings.TrimSpace(m.input.Value()) matches := commandMatches(commandPaletteQuery(m.input.Value()))
count := 0 count := len(matches)
for _, command := range slashCommands { if count > maxCommandPaletteItems {
if strings.HasPrefix(command.Name, prefix) { count = maxCommandPaletteItems
count++
}
} }
if count == 0 { if count == 0 {
count = 1 // "No matching commands" count = 1
} }
return count return 1 + count + m.styles.Palette.GetVerticalFrameSize()
} }
func (m model) showSlashSuggestions() bool { func (m model) showCommandPalette() bool {
value := strings.TrimSpace(m.input.Value()) value := strings.TrimSpace(m.input.Value())
return strings.HasPrefix(value, "/") && !strings.Contains(value, " ") return strings.HasPrefix(value, "/") && !strings.Contains(value, " ")
} }
func (m model) slashSuggestionsView() string { func (m model) commandPaletteView() string {
if !m.showSlashSuggestions() { if !m.showCommandPalette() {
return "" return ""
} }
prefix := strings.TrimSpace(m.input.Value()) matches := commandMatches(commandPaletteQuery(m.input.Value()))
var parts []string lines := []string{m.styles.Header.Render(iconCommand + " Commands")}
if len(matches) == 0 {
lines = append(lines, m.styles.Muted.Render("No matching commands"))
} else {
for i, match := range matches {
if i >= maxCommandPaletteItems {
break
}
lines = append(lines, fmt.Sprintf("%s %s", m.styles.PaletteMatch.Render(match.command.Name), m.styles.Muted.Render(match.command.Description)))
}
}
return m.styles.Palette.Width(max(20, m.width-2)).Render(strings.Join(lines, "\n"))
}
func commandPaletteQuery(value string) string {
value = strings.TrimSpace(value)
if !strings.HasPrefix(value, "/") {
return ""
}
return strings.TrimPrefix(value, "/")
}
func commandMatches(query string) []commandMatch {
matches := make([]commandMatch, 0, len(slashCommands))
for _, command := range slashCommands { for _, command := range slashCommands {
if !strings.HasPrefix(command.Name, prefix) { score, ok := scoreCommand(command, query)
if !ok {
continue continue
} }
parts = append(parts, fmt.Sprintf("%s %s", command.Name, command.Description)) matches = append(matches, commandMatch{command: command, score: score})
} }
if len(parts) == 0 { sort.Slice(matches, func(i, j int) bool {
parts = append(parts, "No matching commands") if matches[i].score != matches[j].score {
return matches[i].score < matches[j].score
}
return matches[i].command.Name < matches[j].command.Name
})
return matches
}
func scoreCommand(command slashCommand, query string) (int, bool) {
query = strings.ToLower(strings.TrimSpace(query))
name := strings.TrimPrefix(strings.ToLower(command.Name), "/")
if query == "" || query == name {
return 0, true
} }
return m.styles.CommandHint.Width(max(20, m.width-2)).Render(strings.Join(parts, "\n")) if strings.HasPrefix(name, query) {
return 100 + len(name) - len(query), true
}
if score, ok := subsequenceScore(name, query, 200); ok {
return score, true
}
candidate := name + " " + strings.ToLower(command.Description)
if score, ok := subsequenceScore(candidate, query, 300); ok {
return score, true
}
return 0, false
}
func subsequenceScore(candidate string, query string, base int) (int, bool) {
candidate = strings.ToLower(candidate)
query = strings.ToLower(query)
if query == "" {
return base, true
}
start := 0
last := -1
gaps := 0
for _, r := range query {
found := -1
for i, c := range candidate[start:] {
if c == r {
found = start + i
break
}
}
if found < 0 {
return 0, false
}
if last >= 0 {
gaps += found - last - 1
}
last = found
start = found + len(string(r))
}
return base + gaps, true
}
func (m model) slashSuggestionCount() int {
return m.commandPaletteHeight()
}
func (m model) showSlashSuggestions() bool {
return m.showCommandPalette()
}
func (m model) slashSuggestionsView() string {
return m.commandPaletteView()
} }
func (m model) modelInfo() string { func (m model) modelInfo() string {
+102 -8
View File
@@ -21,7 +21,7 @@ func TestModelRendersChatShell(t *testing.T) {
m = next.(model) m = next.(model)
rendered := m.View() rendered := m.View()
for _, want := range []string{"test-model", "yolo tools", "light", "ctrl+j", "alt+enter"} { for _, want := range []string{"test-model", "yolo tools", "light", "ctrl+j", "alt+enter", "ctrl+p/ctrl+n history"} {
if !strings.Contains(rendered, want) { if !strings.Contains(rendered, want) {
t.Fatalf("rendered view missing %q:\n%s", want, rendered) t.Fatalf("rendered view missing %q:\n%s", want, rendered)
} }
@@ -123,11 +123,14 @@ func TestRunningStateRendersActivityIndicator(t *testing.T) {
m.resize() m.resize()
rendered := m.View() 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) { if !strings.Contains(rendered, want) {
t.Fatalf("running view missing %q:\n%s", want, rendered) 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) { func TestUserMessageRendersFullWidthBubble(t *testing.T) {
@@ -276,19 +279,110 @@ 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}) m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model) m = next.(model)
m.input.SetValue("/") m.input.SetValue("/mdl")
m.afterInputChanged() m.afterInputChanged()
rendered := m.View() plain := stripANSI(m.View())
for _, want := range []string{"/clear reset context", "/compact compress context", "/exit quit", "/model model/provider/thinking"} { for _, want := range []string{iconCommand + " Commands", "/model model/provider/thinking"} {
if !strings.Contains(rendered, want) { if !strings.Contains(plain, want) {
t.Fatalf("rendered suggestions missing %q:\n%s", want, rendered) 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 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{} type tuiCompactProvider struct{}
+71 -5
View File
@@ -15,6 +15,17 @@ const (
ThemeDark ThemeMode = "dark" ThemeDark ThemeMode = "dark"
) )
const (
iconReady = "✓"
iconWorking = "◆"
iconTool = "▣"
iconError = "✕"
iconCommand = "⌘"
iconSession = "◌"
iconContext = "◔"
iconModel = "◉"
)
func ParseThemeMode(value string) (ThemeMode, error) { func ParseThemeMode(value string) (ThemeMode, error) {
switch ThemeMode(strings.ToLower(strings.TrimSpace(value))) { switch ThemeMode(strings.ToLower(strings.TrimSpace(value))) {
case "", ThemeLight: case "", ThemeLight:
@@ -48,11 +59,23 @@ type styles struct {
App lipgloss.Style App lipgloss.Style
Muted lipgloss.Style Muted lipgloss.Style
Activity lipgloss.Style Activity lipgloss.Style
Header lipgloss.Style
ModelMeta lipgloss.Style ModelMeta lipgloss.Style
Footer lipgloss.Style Footer lipgloss.Style
Viewport lipgloss.Style Viewport lipgloss.Style
InputBox lipgloss.Style InputBox lipgloss.Style
CommandHint lipgloss.Style CommandHint 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 UserLabel lipgloss.Style
ToolLabel lipgloss.Style ToolLabel lipgloss.Style
ErrorLabel lipgloss.Style ErrorLabel lipgloss.Style
@@ -116,14 +139,15 @@ func (t theme) styles() styles {
Foreground(lipgloss.Color(t.Muted)), Foreground(lipgloss.Color(t.Muted)),
Activity: lipgloss.NewStyle(). Activity: lipgloss.NewStyle().
Bold(true). Foreground(lipgloss.Color(t.Muted)).
Foreground(lipgloss.Color(t.Background)).
Background(lipgloss.Color(t.Title)).
Padding(0, 1), Padding(0, 1),
Header: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(t.Title)),
ModelMeta: lipgloss.NewStyle(). ModelMeta: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Text)). Foreground(lipgloss.Color(t.Muted)).
Background(lipgloss.Color(t.SurfaceAlt)).
Padding(0, 1), Padding(0, 1),
Footer: lipgloss.NewStyle(). Footer: lipgloss.NewStyle().
@@ -141,9 +165,51 @@ func (t theme) styles() styles {
CommandHint: lipgloss.NewStyle(). CommandHint: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)). Foreground(lipgloss.Color(t.Muted)).
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)). Background(lipgloss.Color(t.SurfaceAlt)).
Padding(0, 1), 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),
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), UserLabel: labelStyle(t.User),
ToolLabel: labelStyle(t.Tool), ToolLabel: labelStyle(t.Tool),
ErrorLabel: labelStyle(t.Error), ErrorLabel: labelStyle(t.Error),
+17 -11
View File
@@ -13,11 +13,11 @@ import (
"strings" "strings"
"agentu/internal/agent" "agentu/internal/agent"
"agentu/pkg/config"
"agentu/pkg/llm"
"agentu/internal/session" "agentu/internal/session"
"agentu/internal/tools" "agentu/internal/tools"
"agentu/internal/tui" "agentu/internal/tui"
"agentu/pkg/config"
"agentu/pkg/llm"
) )
const defaultConfigContent = `# agentu configuration const defaultConfigContent = `# agentu configuration
@@ -112,15 +112,8 @@ func run() error {
return err return err
} }
// Resume session if err := initializeSession(modelManager, *resumeID); err != nil {
if *resumeID != "" { return err
if err := modelManager.Resume(*resumeID); err != nil {
return fmt.Errorf("resume session: %w", err)
}
} else {
if err := modelManager.ResumeLatest(); err != nil {
return fmt.Errorf("resume latest session: %w", err)
}
} }
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
@@ -141,6 +134,19 @@ func run() error {
return repl(ctx, assistant, modelManager) return repl(ctx, assistant, modelManager)
} }
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 bootstrapConfig(configPath string) error { func bootstrapConfig(configPath string) error {
resolved, err := config.ResolvePath(configPath) resolved, err := config.ResolvePath(configPath)
if err != nil { if err != nil {
+101
View File
@@ -0,0 +1,101 @@
package main
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")
}
}