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