返回 DeepSeek-Reasonix
branch.go
根目录 / internal / cli / branch.go
1 package cli
2
3 import (
4 "fmt"
5 "strings"
6
7 "github.com/charmbracelet/x/ansi"
8
9 "reasonix/internal/agent"
10 "reasonix/internal/control"
11 "reasonix/internal/provider"
12 )
13
14 func (m *chatTUI) showBranchTree() {
15 branches, err := m.ctrl.Branches()
16 m.followSessionLease()
17 if err != nil {
18 m.notice("tree: " + err.Error())
19 return
20 }
21 current := agent.BranchID(m.ctrl.SessionPath())
22 tree := renderBranchTree(control.FormatBranchTree(branches, current))
23 m.commitLine(ansi.Hardwrap(tree, max(m.width, 20), false))
24 }
25
26 func renderBranchTree(tree string) string {
27 lines := strings.Split(tree, "\n")
28 for i, line := range lines {
29 lines[i] = renderBranchTreeLine(line)
30 }
31 return strings.Join(lines, "\n")
32 }
33
34 func renderBranchTreeLine(line string) string {
35 if line == "branches:" {
36 return accent(line)
37 }
38 joint := strings.LastIndex(line, "├─ ")
39 if alt := strings.LastIndex(line, "└─ "); alt > joint {
40 joint = alt
41 }
42 if joint < 0 {
43 return line
44 }
45 treePrefix := line[:joint+len("├─ ")]
46 parts := strings.SplitN(line[joint+len("├─ "):], " ", 3)
47 if len(parts) < 3 {
48 return line
49 }
50 id, title, meta := parts[0], parts[1], parts[2]
51
52 turns := meta
53 current := ""
54 if before, after, ok := strings.Cut(meta, " "); ok {
55 turns = before
56 if strings.TrimSpace(after) == "current" {
57 current = " " + accent("current")
58 } else if strings.TrimSpace(after) != "" {
59 current = " " + after
60 }
61 }
62 return dim(treePrefix) + dim(id) + " " + title + " " + dim(turns) + current
63 }
64
65 func (m *chatTUI) runBranchCommand(input string) {
66 cmd := strings.Fields(input)[0]
67 args := strings.TrimSpace(strings.TrimPrefix(input, cmd))
68
69 // /branch 3 optional-name branches from displayed turn 3. Plain /branch
70 // branches from the current tip.
71 if n, name, fromTurn, err := control.ParseBranchTarget(args); err != nil {
72 m.notice(err.Error())
73 return
74 } else if fromTurn {
75 if _, err := m.ctrl.ForkNamed(n-1, name); err != nil {
76 m.followSessionLease()
77 return
78 }
79 m.followSessionLease()
80 m.replayActiveBranch(fmt.Sprintf("branched from turn %d", n))
81 return
82 } else {
83 if _, err := m.ctrl.Branch(name); err != nil {
84 m.followSessionLease()
85 return
86 }
87 m.followSessionLease()
88 }
89 m.showBranchTree()
90 }
91
92 func (m *chatTUI) runSwitchCommand(input string) {
93 ref := strings.TrimSpace(strings.TrimPrefix(input, strings.Fields(input)[0]))
94 if ref == "" {
95 m.notice("usage: /switch <branch id|name>")
96 return
97 }
98 // Move the session lease before the controller binds the target branch for
99 // writing; a branch held by another runtime is refused here. Resolution
100 // failures fall through to SwitchBranch, which reports them as before.
101 if m.leases != nil {
102 if branches, err := m.ctrl.Branches(); err == nil {
103 m.followSessionLease()
104 if match, err := control.ResolveBranchRef(branches, ref); err == nil {
105 if err := m.rebindSessionLease(match.Path); err != nil {
106 m.notice("switch: " + sessionLeaseHeldNotice(err))
107 return
108 }
109 }
110 } else {
111 m.followSessionLease()
112 }
113 }
114 if _, err := m.ctrl.SwitchBranch(ref); err != nil {
115 // The switch failed after the lease already moved; re-point it at the
116 // session the controller still owns.
117 m.restoreSessionLease()
118 return
119 }
120 m.replayActiveBranch("switched branch")
121 }
122
123 func (m *chatTUI) replayActiveBranch(title string) {
124 m.finalizeStreamed()
125 m.pending.Reset()
126 m.reasoning.Reset()
127 m.todoArgs = ""
128 m.chooser = nil
129 m.pendingApproval = nil
130 m.bubblePending = false
131 m.turnDiscarded = false
132 m.planMode = false
133 m.ctrl.SetPlanMode(false)
134 m.sessionSwitch = true
135
136 // Discard the previous session's transcript so the viewport only shows the
137 // newly loaded session. Without this the transcript accumulates across
138 // every /resume / /switch / /rewind / /branch, bloating memory and causing
139 // the scroll position to be preserved at a stale offset inside the merged
140 // content (#4584).
141 m.clearTranscriptDisplay()
142 m.transcriptDirty = true
143 m.forceGotoBottom = true
144
145 m.commitLine("")
146 if title != "" {
147 m.commitLine(dim(" -- " + title + " --"))
148 }
149 m.commitTranscriptSource(transcriptSource{
150 kind: transcriptSourceReplayBundle,
151 history: append([]provider.Message(nil), m.ctrl.History()...),
152 })
153 }
154
154 lines GO