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
This commit is contained in:
+69
-10
@@ -290,8 +290,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case toolLogMsg:
|
||||
text := cleanToolLog(string(msg))
|
||||
if text != "" {
|
||||
m.messages = append(m.messages, message{role: roleTool, content: text})
|
||||
m.status = text
|
||||
if !m.mergeToolLog(text) {
|
||||
m.messages = append(m.messages, message{role: roleTool, content: text})
|
||||
}
|
||||
m.status = toolStatusText(text)
|
||||
m.refreshViewport(true)
|
||||
}
|
||||
return m, m.waitForEvent()
|
||||
@@ -572,8 +574,9 @@ func roleLabel(r role) string {
|
||||
}
|
||||
|
||||
type toolLogParts struct {
|
||||
name string
|
||||
args string
|
||||
name string
|
||||
args string
|
||||
output string
|
||||
}
|
||||
|
||||
func parseToolLog(content string) toolLogParts {
|
||||
@@ -581,14 +584,21 @@ func parseToolLog(content string) toolLogParts {
|
||||
if content == "" {
|
||||
return toolLogParts{}
|
||||
}
|
||||
idx := strings.IndexFunc(content, unicode.IsSpace)
|
||||
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 {
|
||||
return toolLogParts{name: content}
|
||||
}
|
||||
return toolLogParts{
|
||||
name: strings.TrimSpace(content[:idx]),
|
||||
args: strings.TrimSpace(content[idx+1:]),
|
||||
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 {
|
||||
@@ -599,11 +609,60 @@ func (m model) toolMessageView(content string, width int) string {
|
||||
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
|
||||
|
||||
@@ -348,6 +348,43 @@ func TestToolMessageRendersStructuredCard(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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{}
|
||||
|
||||
func (tuiCompactProvider) ChatStream(ctx context.Context, req llm.ChatRequest, emit func(llm.StreamEvent) error) error {
|
||||
|
||||
Reference in New Issue
Block a user