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}