session_title.go RawHistoryFolder
1package modes
2
3import (
4 "context"
5 "strings"
6 "time"
7
8 "github.com/bnema/zut/packages/core"
9 "github.com/bnema/zut/packages/provider"
10 "github.com/bnema/zut/packages/tui"
11)
12
13const sessionTitleGenerationTimeout = 10 * time.Second
14
15func terminalTitleEnabled(enabled *bool) bool {
16 return enabled == nil || *enabled
17}
18
19// hasRealUserPrompt ignores transcript rows that are context for the model but
20// not a prompt that started work: shell escapes and image mirrors are both
21// user-role messages internally.
22func hasRealUserPrompt(messages []provider.Message) bool {
23 for _, message := range messages {
24 if message.Role != provider.RoleUser || isHiddenTranscriptMessage(message) || message.Meta[shellEscapeMetaKey] == "true" {
25 continue
26 }
27 if strings.TrimSpace(userMessageText(message)) != "" || len(message.Content) > 0 {
28 return true
29 }
30 }
31 return false
32}
33
34// maybeStartSessionTitle marks the first real prompt and starts the hidden
35// title request. It is safe to call at every prompt boundary; the state guard
36// makes it a one-shot per interactive session/branch.
37func (i *Interactive) maybeStartSessionTitle(parent context.Context, prompt string) {
38 if parent == nil {
39 parent = i.runCtx
40 }
41 if parent == nil {
42 parent = context.Background()
43 }
44
45 i.mu.Lock()
46 if !i.interactiveStarted || i.agent == nil || i.titleRealPromptSeen || i.titleGenerationStarted {
47 i.mu.Unlock()
48 return
49 }
50 i.titleRealPromptSeen = true
51 i.firstRealPrompt = prompt
52 if !terminalTitleEnabled(i.cfg.TerminalTitleEnabled) {
53 i.mu.Unlock()
54 return
55 }
56 i.titleGenerationStarted = true
57 agent := i.agent
58 client := agent.Client
59 model := agent.Model
60 version := i.titleVersion
61 i.mu.Unlock()
62
63 if strings.TrimSpace(prompt) == "" {
64 i.finishSessionTitle(version, "new session")
65 return
66 }
67
68 ctx, cancel := context.WithTimeout(parent, sessionTitleGenerationTimeout)
69 i.mu.Lock()
70 if version != i.titleVersion {
71 i.mu.Unlock()
72 cancel()
73 return
74 }
75 i.titleCancel = cancel
76 i.mu.Unlock()
77
78 go func() {
79 defer cancel()
80 title, err := core.GenerateSessionTitle(ctx, client, model, prompt)
81 if err != nil {
82 // Title generation is deliberately best effort. The main turn
83 // must not depend on this second request succeeding.
84 title = core.NormalizeSessionTitle(prompt)
85 }
86 if parent.Err() != nil {
87 return
88 }
89 if title == "" {
90 title = "new session"
91 }
92 i.finishSessionTitle(version, title)
93 }()
94}
95
96// finishSessionTitle applies a generated or fallback title only if the
97// session has not been switched, forked, or manually renamed meanwhile.
98func (i *Interactive) finishSessionTitle(version uint64, title string) {
99 title = core.NormalizeSessionTitle(title)
100 if title == "" {
101 title = "new session"
102 }
103
104 i.mu.Lock()
105 if version != i.titleVersion || !terminalTitleEnabled(i.cfg.TerminalTitleEnabled) {
106 i.mu.Unlock()
107 return
108 }
109 i.titleCancel = nil
110 i.sessionTitle = title
111 if i.cfg.PersistTitle != nil {
112 if err := i.cfg.PersistTitle(title); err != nil {
113 i.statusErr = "session title: " + err.Error()
114 }
115 }
116 i.writeTerminalTitleLocked(title)
117 i.mu.Unlock()
118 i.invalidate()
119}
120
121func (i *Interactive) writeTerminalTitleLocked(subject string) {
122 if i.cfg.Terminal == nil {
123 return
124 }
125 subject = core.NormalizeSessionTitle(subject)
126 if subject != "" && !terminalTitleEnabled(i.cfg.TerminalTitleEnabled) {
127 return
128 }
129 display := "zut"
130 if subject != "" {
131 display += ": " + subject
132 }
133 _, _ = i.cfg.Terminal.Write([]byte(tui.SetTitle(display)))
134}
135
136func (i *Interactive) markSessionTitleSwitching() {
137 i.mu.Lock()
138 cancel := i.titleCancel
139 i.titleCancel = nil
140 i.titleVersion++
141 i.titleGenerationStarted = true
142 i.mu.Unlock()
143 if cancel != nil {
144 cancel()
145 }
146}
147
148// CancelPendingSessionTitle invalidates a title request before a host swaps
149// the session it owns. Hosts must call it before changing their session
150// pointer so a late result cannot be persisted into the replacement session.
151func (i *Interactive) CancelPendingSessionTitle() {
152 i.markSessionTitleSwitching()
153}
154
155func (i *Interactive) cancelSessionTitle() {
156 i.mu.Lock()
157 cancel := i.titleCancel
158 i.titleCancel = nil
159 i.titleVersion++
160 i.mu.Unlock()
161 if cancel != nil {
162 cancel()
163 }
164}
165
166func (i *Interactive) restoreLoadedSessionTitle() {
167 pending := false
168 if i.cfg.CurrentSessionTitlePending != nil {
169 pending = i.cfg.CurrentSessionTitlePending()
170 }
171 title := ""
172 if !pending && i.cfg.CurrentSessionTitle != nil {
173 title = i.cfg.CurrentSessionTitle()
174 }
175
176 i.mu.Lock()
177 i.titleVersion++
178 i.sessionTitle = core.NormalizeSessionTitle(title)
179 i.firstRealPrompt = ""
180 if pending {
181 i.titleRealPromptSeen = false
182 i.titleGenerationStarted = false
183 } else {
184 i.titleRealPromptSeen = hasRealUserPrompt(i.agentMessagesLocked())
185 i.titleGenerationStarted = i.titleRealPromptSeen || i.sessionTitle != ""
186 }
187 i.writeTerminalTitleLocked(i.sessionTitle)
188 i.mu.Unlock()
189}
190
191func (i *Interactive) restoreFailedSessionTitle() {
192 i.mu.Lock()
193 prompt := i.firstRealPrompt
194 i.mu.Unlock()
195 if prompt == "" {
196 i.restoreLoadedSessionTitle()
197 return
198 }
199 i.restoreLoadedSessionTitle()
200 i.mu.Lock()
201 if i.sessionTitle != "" {
202 i.mu.Unlock()
203 return
204 }
205 i.firstRealPrompt = prompt
206 i.titleRealPromptSeen = false
207 i.titleGenerationStarted = false
208 i.mu.Unlock()
209 i.maybeStartSessionTitle(i.runCtx, prompt)
210}
211
212func (i *Interactive) resetSessionTitleForFreshBranch() {
213 i.mu.Lock()
214 cancel := i.titleCancel
215 i.titleCancel = nil
216 i.titleVersion++
217 i.sessionTitle = ""
218 i.titleRealPromptSeen = false
219 i.titleGenerationStarted = false
220 i.firstRealPrompt = ""
221 i.writeTerminalTitleLocked("")
222 i.mu.Unlock()
223 if cancel != nil {
224 cancel()
225 }
226}
227
228func (i *Interactive) agentMessagesLocked() []provider.Message {
229 if i.agent == nil {
230 return nil
231 }
232 return i.agent.Messages()
233}
234
235func (i *Interactive) setManualSessionTitle(title string) {
236 title = core.NormalizeSessionTitle(title)
237 if title == "" {
238 return
239 }
240 i.mu.Lock()
241 cancel := i.titleCancel
242 i.titleCancel = nil
243 i.titleVersion++
244 i.sessionTitle = title
245 i.titleRealPromptSeen = true
246 i.titleGenerationStarted = true
247 i.firstRealPrompt = ""
248 i.writeTerminalTitleLocked(title)
249 onChanged := i.cfg.OnSessionTitleChanged
250 i.mu.Unlock()
251 if cancel != nil {
252 cancel()
253 }
254 if onChanged != nil {
255 onChanged(title)
256 }
257}
258
259func (i *Interactive) markInteractiveStarted() {
260 i.mu.Lock()
261 i.interactiveStarted = true
262 if i.sessionTitle != "" {
263 i.writeTerminalTitleLocked(i.sessionTitle)
264 }
265 i.mu.Unlock()
266}