Files
2026-07-13 13:00:08 +08:00

180 lines
4.1 KiB
Go

package control
import (
"fmt"
"strconv"
"strings"
"reasonix/internal/agent"
)
// ParseBranchTarget parses the arguments after "/branch". A leading positive
// integer means "branch from displayed turn N"; otherwise the whole argument is
// the optional branch name for a tip branch.
func ParseBranchTarget(args string) (turn int, name string, fromTurn bool, err error) {
args = strings.TrimSpace(args)
fields := strings.Fields(args)
if len(fields) == 0 {
return 0, "", false, nil
}
n, convErr := strconv.Atoi(fields[0])
if convErr != nil {
return 0, args, false, nil
}
if n <= 0 {
return 0, "", false, fmt.Errorf("usage: /branch [turn] [name]")
}
name = strings.TrimSpace(strings.TrimPrefix(args, fields[0]))
return n, name, true, nil
}
func (c *Controller) BranchTreeText() string {
branches, err := c.Branches()
if err != nil {
return "branches: " + err.Error()
}
return FormatBranchTree(branches, agent.BranchID(c.SessionPath()))
}
func FormatBranchTree(branches []agent.BranchInfo, currentID string) string {
if len(branches) == 0 {
return "branches: none"
}
byID := map[string]agent.BranchInfo{}
children := map[string][]agent.BranchInfo{}
for _, b := range branches {
byID[b.ID] = b
}
var roots []agent.BranchInfo
for _, b := range branches {
if b.ParentID == "" {
roots = append(roots, b)
continue
}
if _, ok := byID[b.ParentID]; !ok {
roots = append(roots, b)
continue
}
children[b.ParentID] = append(children[b.ParentID], b)
}
var out strings.Builder
out.WriteString("branches:\n")
seen := map[string]bool{}
var walk func(agent.BranchInfo, string, bool, int)
walk = func(b agent.BranchInfo, prefix string, last bool, depth int) {
if seen[b.ID] {
return
}
seen[b.ID] = true
joint := "├─"
childPrefix := prefix + "│ "
if last {
joint = "└─"
childPrefix = prefix + " "
}
current := ""
if b.ID == currentID {
current = " current"
}
fmt.Fprintf(&out, "%s%s %s %s %s%s\n",
prefix, joint, shortBranchID(b.ID), branchTitle(b, depth), turnText(b.Turns), current)
for i, child := range children[b.ID] {
walk(child, childPrefix, i == len(children[b.ID])-1, depth+1)
}
}
for i, root := range roots {
walk(root, "", i == len(roots)-1, 0)
}
for _, b := range branches {
walk(b, "", true, 0)
}
return strings.TrimRight(out.String(), "\n")
}
func branchTitle(b agent.BranchInfo, depth int) string {
title := strings.TrimSpace(b.Name)
if title == "" {
title = strings.TrimSpace(b.Preview)
}
if label, ok := structuredBranchLabel(title); ok {
return label
}
maxRunes := 32 - depth*4
if maxRunes < 18 {
maxRunes = 18
}
title = oneLineBranch(title, maxRunes)
if title == "" {
return "(untitled)"
}
return title
}
func structuredBranchLabel(s string) (string, bool) {
s = strings.TrimSpace(s)
if s == "" {
return "", false
}
switch s[0] {
case '{':
lower := strings.ToLower(s)
switch {
case strings.Contains(lower, `"msg"`) && strings.Contains(lower, "success"):
return "JSON response: success", true
case strings.Contains(lower, `"error"`) || strings.Contains(lower, `"errors"`):
return "JSON payload: error", true
default:
return "JSON object", true
}
case '[':
return "JSON array", true
default:
return "", false
}
}
func turnText(n int) string {
if n == 1 {
return "1 turn"
}
return fmt.Sprintf("%d turns", n)
}
func shortBranchID(id string) string {
if len(id) >= 16 && numeric(id[:8]) && id[8] == '-' && numeric(id[9:15]) && id[15] == '.' {
fracEnd := 16
for fracEnd < len(id) && fracEnd < 19 && id[fracEnd] >= '0' && id[fracEnd] <= '9' {
fracEnd++
}
if fracEnd > 16 {
return id[4:8] + "-" + id[9:15] + "." + id[16:fracEnd]
}
return id[4:8] + "-" + id[9:15]
}
return oneLineBranch(id, 18)
}
func numeric(s string) bool {
for _, ch := range s {
if ch < '0' || ch > '9' {
return false
}
}
return s != ""
}
func oneLineBranch(s string, maxRunes int) string {
s = strings.Join(strings.Fields(s), " ")
if maxRunes <= 0 {
return s
}
r := []rune(s)
if len(r) <= maxRunes {
return s
}
if maxRunes <= 1 {
return string(r[:maxRunes])
}
return string(r[:maxRunes-1]) + "..."
}