v0.2.0 42a5079

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close