Initial commit on Forgejo
Fresh repository history for elektrine/tarakan-client hosted at https://git.elektrine.com/elektrine/tarakan-client.
This commit is contained in:
commit
58b29a7f84
70 changed files with 12994 additions and 0 deletions
281
internal/agent/claude_stream.go
Normal file
281
internal/agent/claude_stream.go
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// runClaude streams Claude Code stream-json events so the TUI can show tool
|
||||
// use (Read, Bash, Task/subagents) while the run is in progress.
|
||||
func runClaude(ctx context.Context, provider Provider, request Request) (string, error) {
|
||||
if provider.Path == "" {
|
||||
return "", ErrUnavailable
|
||||
}
|
||||
args := []string{
|
||||
"-p", securityPrompt(request.Prompt),
|
||||
"--output-format", "stream-json",
|
||||
"--verbose",
|
||||
// Isolated snapshot; unattended tool use for the review run.
|
||||
"--dangerously-skip-permissions",
|
||||
}
|
||||
command := exec.CommandContext(ctx, provider.Path, args...)
|
||||
command.Dir = request.Directory
|
||||
command.Env = subprocessEnvironment(os.Environ())
|
||||
|
||||
stdout, err := command.StdoutPipe()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var stderrBuf bytes.Buffer
|
||||
command.Stderr = &stderrBuf
|
||||
|
||||
if err := command.Start(); err != nil {
|
||||
return "", fmt.Errorf("%s failed to start: %w", provider.Description, err)
|
||||
}
|
||||
|
||||
progress := request.Progress
|
||||
report := func(line string) {
|
||||
if progress == nil {
|
||||
return
|
||||
}
|
||||
if line = strings.TrimSpace(line); line != "" {
|
||||
progress(line)
|
||||
}
|
||||
}
|
||||
var (
|
||||
lastFooter string
|
||||
footerMu sync.Mutex
|
||||
)
|
||||
reportFooter := func(line string) {
|
||||
if progress == nil {
|
||||
return
|
||||
}
|
||||
footerMu.Lock()
|
||||
defer footerMu.Unlock()
|
||||
if line == lastFooter {
|
||||
return
|
||||
}
|
||||
lastFooter = line
|
||||
progress(line)
|
||||
}
|
||||
|
||||
var (
|
||||
finalResult string
|
||||
textBuf strings.Builder
|
||||
seen = map[string]struct{}{}
|
||||
)
|
||||
emit := func(line string) {
|
||||
for _, part := range strings.Split(line, "\n") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
key := normalizeActivityKey(part)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
report(part)
|
||||
}
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
for scanner.Scan() {
|
||||
ev := parseClaudeStreamLine(scanner.Text())
|
||||
if !ev.ok {
|
||||
continue
|
||||
}
|
||||
if ev.activity != "" {
|
||||
emit(ev.activity)
|
||||
}
|
||||
if ev.footer != "" {
|
||||
reportFooter(ev.footer)
|
||||
}
|
||||
if ev.assistantText != "" {
|
||||
textBuf.WriteString(ev.assistantText)
|
||||
}
|
||||
if ev.finalResult != "" {
|
||||
finalResult = ev.finalResult
|
||||
}
|
||||
}
|
||||
_ = scanner.Err()
|
||||
|
||||
waitErr := command.Wait()
|
||||
output := strings.TrimSpace(finalResult)
|
||||
if output == "" {
|
||||
output = strings.TrimSpace(textBuf.String())
|
||||
}
|
||||
if waitErr != nil {
|
||||
errText := strings.TrimSpace(stderrBuf.String())
|
||||
if output == "" && errText != "" {
|
||||
output = errText
|
||||
}
|
||||
return output, fmt.Errorf("%s failed: %w", provider.Description, waitErr)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
type claudeStreamEvent struct {
|
||||
ok bool
|
||||
activity string
|
||||
footer string
|
||||
assistantText string
|
||||
finalResult string
|
||||
}
|
||||
|
||||
func parseClaudeStreamLine(line string) claudeStreamEvent {
|
||||
var row map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &row); err != nil {
|
||||
return claudeStreamEvent{}
|
||||
}
|
||||
typ, _ := row["type"].(string)
|
||||
switch typ {
|
||||
case "system":
|
||||
if sub, _ := row["subtype"].(string); sub == "init" {
|
||||
return claudeStreamEvent{ok: true, activity: "→ Claude session started"}
|
||||
}
|
||||
case "assistant":
|
||||
msg, _ := row["message"].(map[string]any)
|
||||
content, _ := msg["content"].([]any)
|
||||
var acts []string
|
||||
var texts []string
|
||||
footer := ""
|
||||
for _, block := range content {
|
||||
b, _ := block.(map[string]any)
|
||||
switch b["type"] {
|
||||
case "tool_use":
|
||||
name, _ := b["name"].(string)
|
||||
input, _ := b["input"].(map[string]any)
|
||||
if label := formatClaudeTool(name, input); label != "" {
|
||||
acts = append(acts, "→ "+label)
|
||||
}
|
||||
case "text":
|
||||
if t, _ := b["text"].(string); strings.TrimSpace(t) != "" {
|
||||
texts = append(texts, t)
|
||||
footer = "… writing response"
|
||||
}
|
||||
case "thinking":
|
||||
footer = "… thinking"
|
||||
}
|
||||
}
|
||||
return claudeStreamEvent{
|
||||
ok: true,
|
||||
activity: strings.Join(acts, "\n"),
|
||||
footer: footer,
|
||||
assistantText: strings.Join(texts, ""),
|
||||
}
|
||||
case "user":
|
||||
msg, _ := row["message"].(map[string]any)
|
||||
content, _ := msg["content"].([]any)
|
||||
for _, block := range content {
|
||||
b, _ := block.(map[string]any)
|
||||
if b["type"] == "tool_result" {
|
||||
return claudeStreamEvent{ok: true, footer: "… tool finished"}
|
||||
}
|
||||
}
|
||||
case "result":
|
||||
ev := claudeStreamEvent{ok: true}
|
||||
if r, _ := row["result"].(string); strings.TrimSpace(r) != "" {
|
||||
ev.finalResult = r
|
||||
}
|
||||
if isErr, _ := row["is_error"].(bool); isErr {
|
||||
if e, _ := row["error"].(string); e != "" {
|
||||
ev.activity = "Claude error: " + e
|
||||
}
|
||||
}
|
||||
return ev
|
||||
case "stream_event":
|
||||
return claudeStreamEvent{ok: true, footer: "… streaming"}
|
||||
}
|
||||
return claudeStreamEvent{}
|
||||
}
|
||||
|
||||
func formatClaudeTool(name string, input map[string]any) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if input == nil {
|
||||
input = map[string]any{}
|
||||
}
|
||||
switch name {
|
||||
case "Read", "read_file":
|
||||
path := firstString(input, "file_path", "path", "target_file")
|
||||
if path != "" {
|
||||
return "Read " + path
|
||||
}
|
||||
return "Read file"
|
||||
case "Bash", "bash", "Shell":
|
||||
cmd := firstString(input, "command")
|
||||
desc := firstString(input, "description")
|
||||
if desc != "" {
|
||||
return "Shell: " + desc
|
||||
}
|
||||
if cmd != "" {
|
||||
return "Shell: " + truncateRunes(cmd, 80)
|
||||
}
|
||||
return "Shell"
|
||||
case "Grep", "grep":
|
||||
pat := firstString(input, "pattern")
|
||||
path := firstString(input, "path")
|
||||
if pat != "" && path != "" {
|
||||
return "Grep " + quoteShort(pat) + " in " + path
|
||||
}
|
||||
if pat != "" {
|
||||
return "Grep " + quoteShort(pat)
|
||||
}
|
||||
return "Grep"
|
||||
case "Glob", "glob":
|
||||
pat := firstString(input, "pattern", "glob_pattern")
|
||||
if pat != "" {
|
||||
return "Glob " + pat
|
||||
}
|
||||
return "Glob"
|
||||
case "Edit", "Write", "MultiEdit", "NotebookEdit":
|
||||
path := firstString(input, "file_path", "path")
|
||||
if path != "" {
|
||||
return "Edit " + path
|
||||
}
|
||||
return "Edit file"
|
||||
case "Task", "Agent", "TaskCreate":
|
||||
desc := firstString(input, "description", "prompt")
|
||||
sub := firstString(input, "subagent_type", "agent")
|
||||
if sub != "" && desc != "" {
|
||||
return "Subagent " + sub + ": " + truncateRunes(desc, 60)
|
||||
}
|
||||
if sub != "" {
|
||||
return "Subagent " + sub
|
||||
}
|
||||
if desc != "" {
|
||||
return "Subagent: " + truncateRunes(desc, 60)
|
||||
}
|
||||
return "Subagent"
|
||||
case "WebSearch", "WebFetch":
|
||||
q := firstString(input, "query", "url")
|
||||
if q != "" {
|
||||
return name + ": " + truncateRunes(q, 60)
|
||||
}
|
||||
return name
|
||||
case "LS", "list_dir":
|
||||
path := firstString(input, "path", "target_directory")
|
||||
if path != "" {
|
||||
return "List " + path
|
||||
}
|
||||
return "List directory"
|
||||
default:
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
for _, k := range []string{"file_path", "path", "command", "query", "description", "pattern"} {
|
||||
if v := firstString(input, k); v != "" {
|
||||
return name + " " + truncateRunes(v, 60)
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue