v0.45.0 cb619f7

title.go RawHistoryFolder

1package core
2
3import (
4	"context"
5	"fmt"
6	"strings"
7	"unicode"
8
9	"github.com/bnema/zut/packages/provider"
10)
11
12// SessionTitleMaxChars is the maximum length of an automatically generated
13// session title. The limit is measured in Unicode code points so a title never
14// cuts a UTF-8 sequence in half.
15const SessionTitleMaxChars = 40
16
17const sessionTitleMaxOutputTokens = 16
18const sessionTitlePromptMaxChars = 12000
19
20const sessionTitleSystemPrompt = `You create concise titles for coding sessions.
21Return only the subject title for the user's task. Maximum 40 characters. Use one
22line. Do not include quotes, Markdown, explanations, or trailing punctuation.`
23
24// GenerateSessionTitle asks the provider for a short title without touching a
25// conversation transcript. It is intentionally separate from Agent.Prompt:
26// title generation is UI/session metadata, not an assistant turn.
27func GenerateSessionTitle(ctx context.Context, client provider.Client, model, prompt string) (string, error) {
28	if client == nil {
29		return "", fmt.Errorf("title generation: provider client is nil")
30	}
31	if strings.TrimSpace(prompt) == "" {
32		return "", fmt.Errorf("title generation: prompt is empty")
33	}
34
35	promptRunes := []rune(prompt)
36	if len(promptRunes) > sessionTitlePromptMaxChars {
37		prompt = string(promptRunes[:sessionTitlePromptMaxChars])
38	}
39	stream, err := client.Stream(ctx, provider.Request{
40		Model:     model,
41		System:    sessionTitleSystemPrompt,
42		Messages:  []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: prompt}}}},
43		MaxTokens: sessionTitleMaxOutputTokens,
44	})
45	if err != nil {
46		return "", fmt.Errorf("title generation request: %w", err)
47	}
48
49	var (
50		text     strings.Builder
51		done     bool
52		finalErr error
53		finalMsg provider.Message
54	)
55	streamOpen := true
56	for streamOpen && !done {
57		select {
58		case <-ctx.Done():
59			return "", fmt.Errorf("title generation stream: %w", ctx.Err())
60		case event, ok := <-stream:
61			if !ok {
62				streamOpen = false
63				continue
64			}
65			switch e := event.(type) {
66			case provider.EventTextDelta:
67				text.WriteString(e.Delta)
68			case provider.EventDone:
69				done = true
70				finalErr = e.Err
71				finalMsg = e.Message
72			}
73		}
74	}
75	if finalErr != nil {
76		return "", fmt.Errorf("title generation stream: %w", finalErr)
77	}
78	if !done {
79		if err := ctx.Err(); err != nil {
80			return "", fmt.Errorf("title generation stream: %w", err)
81		}
82		return "", fmt.Errorf("title generation stream ended without a result")
83	}
84	if text.Len() == 0 {
85		text.WriteString(textFromMessage(finalMsg))
86	}
87	if title := NormalizeSessionTitle(text.String()); title != "" {
88		return title, nil
89	}
90	return "", fmt.Errorf("title generation returned an empty title")
91}
92
93// NormalizeSessionTitle turns model or user text into one safe, compact
94// session-title value. It is also used for local fallbacks before a title is
95// sent to a terminal escape sequence.
96func NormalizeSessionTitle(text string) string {
97	text = strings.Map(func(r rune) rune {
98		if unicode.IsControl(r) || r == 0x7f {
99			return -1
100		}
101		return r
102	}, text)
103	text = strings.TrimSpace(text)
104	text = strings.Trim(text, "\"'`")
105	text = strings.TrimSpace(text)
106	for _, prefix := range []string{"title:", "subject:"} {
107		if len(text) >= len(prefix) && strings.EqualFold(text[:len(prefix)], prefix) {
108			text = strings.TrimSpace(text[len(prefix):])
109			break
110		}
111	}
112	text = strings.Join(strings.Fields(text), " ")
113	text = strings.Trim(text, "\"'`")
114	text = strings.TrimSpace(text)
115	if text == "" {
116		return ""
117	}
118	runes := []rune(text)
119	if len(runes) > SessionTitleMaxChars {
120		if SessionTitleMaxChars == 1 {
121			return string(runes[:1])
122		}
123		return string(runes[:SessionTitleMaxChars-1]) + "…"
124	}
125	return text
126}
127
128func textFromMessage(message provider.Message) string {
129	var text strings.Builder
130	for _, content := range message.Content {
131		if block, ok := content.(provider.TextBlock); ok {
132			text.WriteString(block.Text)
133		}
134	}
135	return text.String()
136}

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