wip: P1 feature batch — retry, search, code symbols, diff/status/test commands, tool output head+tail
- retry logic for transient LLM errors (openai.go) - file_search context_lines + max_results (file.go) - FormatResult now preserves head+tail with omitted bytes (tools.go) - /status /diff /test slash commands wired (workflow.go, app.go) - CodeSymbolsTool for Go package/type/func listing (code.go) - README updated with v0.0.4 features
This commit is contained in:
+30
-18
@@ -28,6 +28,7 @@ type Options struct {
|
||||
Yolo bool
|
||||
ThemeMode ThemeMode
|
||||
ModelManager ModelManager
|
||||
WorkingDir string
|
||||
}
|
||||
|
||||
type ModelManager interface {
|
||||
@@ -69,13 +70,14 @@ type message struct {
|
||||
}
|
||||
|
||||
type model struct {
|
||||
ctx context.Context
|
||||
agent *agent.Agent
|
||||
modelName string
|
||||
yolo bool
|
||||
models ModelManager
|
||||
theme theme
|
||||
styles styles
|
||||
ctx context.Context
|
||||
agent *agent.Agent
|
||||
modelName string
|
||||
yolo bool
|
||||
models ModelManager
|
||||
workingDir string
|
||||
theme theme
|
||||
styles styles
|
||||
|
||||
viewport viewport.Model
|
||||
input textarea.Model
|
||||
@@ -126,6 +128,9 @@ var slashCommands = []slashCommand{
|
||||
{Name: "/resume", Description: "resume session"},
|
||||
{Name: "/rename", Description: "rename session"},
|
||||
{Name: "/new", Description: "new session"},
|
||||
{Name: "/status", Description: "show changed files"},
|
||||
{Name: "/diff", Description: "show unstaged diff"},
|
||||
{Name: "/test", Description: "run project tests"},
|
||||
}
|
||||
|
||||
func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model {
|
||||
@@ -155,17 +160,18 @@ func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model {
|
||||
vp.SetContent("")
|
||||
|
||||
return model{
|
||||
ctx: ctx,
|
||||
agent: assistant,
|
||||
modelName: opts.ModelName,
|
||||
yolo: opts.Yolo,
|
||||
models: opts.ModelManager,
|
||||
theme: th,
|
||||
styles: st,
|
||||
viewport: vp,
|
||||
input: input,
|
||||
spinner: spin,
|
||||
status: "ready",
|
||||
ctx: ctx,
|
||||
agent: assistant,
|
||||
modelName: opts.ModelName,
|
||||
yolo: opts.Yolo,
|
||||
models: opts.ModelManager,
|
||||
workingDir: opts.WorkingDir,
|
||||
theme: th,
|
||||
styles: st,
|
||||
viewport: vp,
|
||||
input: input,
|
||||
spinner: spin,
|
||||
status: "ready",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -763,6 +769,12 @@ func (m model) handleLocalCommand(input string) (string, error) {
|
||||
return m.handleRenameCommand(fields[1:])
|
||||
case "/new":
|
||||
return m.handleNewCommand()
|
||||
case "/status":
|
||||
return m.handleStatusCommand(fields[1:])
|
||||
case "/diff":
|
||||
return m.handleDiffCommand(fields[1:])
|
||||
case "/test":
|
||||
return m.handleTestCommand(fields[1:])
|
||||
default:
|
||||
return "", fmt.Errorf("unknown command: %s", fields[0])
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -271,6 +274,59 @@ func TestSlashCommandSuggestions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowStatusDiffAndTestCommands(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not installed")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
runInDir(t, dir, "git", "init")
|
||||
runInDir(t, dir, "git", "config", "user.email", "agentu@example.test")
|
||||
runInDir(t, dir, "git", "config", "user.name", "agentu")
|
||||
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/p1\n\ngo 1.26\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "main_test.go"), []byte("package p1\n\nimport \"testing\"\n\nfunc TestOK(t *testing.T) {}\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runInDir(t, dir, "git", "add", ".")
|
||||
runInDir(t, dir, "git", "commit", "-m", "init")
|
||||
if err := os.WriteFile(filepath.Join(dir, "main_test.go"), []byte("package p1\n\nimport \"testing\"\n\nfunc TestOK(t *testing.T) { t.Log(\"changed\") }\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model", WorkingDir: dir})
|
||||
status, err := m.handleStatusCommand(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(status, "main_test.go") {
|
||||
t.Fatalf("status missing changed file:\n%s", status)
|
||||
}
|
||||
diff, err := m.handleDiffCommand(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(diff, "changed") {
|
||||
t.Fatalf("diff missing changed content:\n%s", diff)
|
||||
}
|
||||
out, err := m.handleTestCommand(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "$ go test ./...") {
|
||||
t.Fatalf("test output missing default command:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func runInDir(t *testing.T, dir string, name string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Dir = dir
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("%s %v failed: %v\n%s", name, args, err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelCommand(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true})
|
||||
m.input.SetValue("/model")
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agentu/internal/tools"
|
||||
)
|
||||
|
||||
const workflowCommandTimeout = 2 * time.Minute
|
||||
|
||||
func (m model) handleStatusCommand(args []string) (string, error) {
|
||||
if len(args) != 0 {
|
||||
return "", fmt.Errorf("usage: /status")
|
||||
}
|
||||
out, err := m.runGitCommand("status", "--short")
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if strings.TrimSpace(out) == "" {
|
||||
return "Working tree clean.", nil
|
||||
}
|
||||
return "Changed files:\n" + out, nil
|
||||
}
|
||||
|
||||
func (m model) handleDiffCommand(args []string) (string, error) {
|
||||
if len(args) > 0 && args[0] == "--stat" {
|
||||
out, err := m.runGitCommand(append([]string{"diff", "--stat", "--"}, args[1:]...)...)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if strings.TrimSpace(out) == "" {
|
||||
return "No unstaged diff.", nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
out, err := m.runGitCommand(append([]string{"diff", "--"}, args...)...)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if strings.TrimSpace(out) == "" {
|
||||
return "No unstaged diff.", nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m model) handleTestCommand(args []string) (string, error) {
|
||||
command := strings.TrimSpace(strings.Join(args, " "))
|
||||
if command == "" {
|
||||
var err error
|
||||
command, err = defaultTestCommand(m.workflowDir())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
out, err := m.runShellCommand(command)
|
||||
if err != nil {
|
||||
if strings.TrimSpace(out) != "" {
|
||||
return out, err
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(out) == "" {
|
||||
return fmt.Sprintf("$ %s\n(no output)", command), nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m model) runGitCommand(args ...string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(m.ctx, workflowCommandTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "git", append([]string{"-C", m.workflowDir()}, args...)...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
text := tools.FormatResult(string(output))
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
return text, fmt.Errorf("git command timed out: %w", ctx.Err())
|
||||
}
|
||||
if err != nil {
|
||||
if strings.TrimSpace(text) != "" {
|
||||
return text, fmt.Errorf("git command failed: %w", err)
|
||||
}
|
||||
return "", fmt.Errorf("git command failed: %w", err)
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func (m model) runShellCommand(command string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(m.ctx, workflowCommandTimeout)
|
||||
defer cancel()
|
||||
|
||||
shell := os.Getenv("SHELL")
|
||||
if shell == "" {
|
||||
shell = "/bin/sh"
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, shell, "-lc", command)
|
||||
cmd.Dir = m.workflowDir()
|
||||
|
||||
var combined bytes.Buffer
|
||||
cmd.Stdout = &combined
|
||||
cmd.Stderr = &combined
|
||||
err := cmd.Run()
|
||||
text := strings.TrimRight(tools.FormatResult(combined.String()), "\n")
|
||||
if text != "" {
|
||||
text = fmt.Sprintf("$ %s\n%s", command, text)
|
||||
}
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
return text, fmt.Errorf("command timed out: %w", ctx.Err())
|
||||
}
|
||||
if err != nil {
|
||||
return text, fmt.Errorf("command failed: %w", err)
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func (m model) workflowDir() string {
|
||||
if strings.TrimSpace(m.workingDir) == "" {
|
||||
return "."
|
||||
}
|
||||
return m.workingDir
|
||||
}
|
||||
|
||||
func defaultTestCommand(dir string) (string, error) {
|
||||
if fileExists(filepath.Join(dir, "go.mod")) {
|
||||
return "go test ./...", nil
|
||||
}
|
||||
if fileExists(filepath.Join(dir, "package.json")) {
|
||||
return "npm test", nil
|
||||
}
|
||||
return "", fmt.Errorf("no default test command found; use /test <command>")
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
Reference in New Issue
Block a user