@@ -16,6 +16,7 @@ import (
17 "log/slog"
17 "log/slog"
@@ -39,12 +40,17 @@ import (
39 "github.com/charmbracelet/crush/internal/stringext"
40 "github.com/charmbracelet/crush/internal/stringext"
43+const defaultSessionName = "Untitled Session"
42//go:embed templates/title.md
45//go:embed templates/title.md
43var titlePrompt []byte
46var titlePrompt []byte
45//go:embed templates/summary.md
48//go:embed templates/summary.md
46var summaryPrompt []byte
49var summaryPrompt []byte
51+// Used to remove <think> tags from generated titles.
52+var thinkTagRegex = regexp.MustCompile(`<think>.*?</think>`)
48type SessionAgentCall struct {
54type SessionAgentCall struct {
49 SessionID string
55 SessionID string
50 Prompt string
56 Prompt string
@@ -725,49 +731,68 @@ func (a *sessionAgent) getSessionMessages(ctx context.Context, session session.S
725 return msgs, nil
731 return msgs, nil
734+// generateTitle generates a session titled based on the initial prompt.
728−func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, prompt string) {
735+func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, userPrompt string) {
729− if prompt == "" {
736+ if userPrompt == "" {
733− var maxOutput int64 = 40
740+ var maxOutputTokens int64 = 40
734 if a.smallModel.CatwalkCfg.CanReason {
741 if a.smallModel.CatwalkCfg.CanReason {
735− maxOutput = a.smallModel.CatwalkCfg.DefaultMaxTokens
742+ maxOutputTokens = a.smallModel.CatwalkCfg.DefaultMaxTokens
745+ newAgent := func(m fantasy.LanguageModel, p []byte, tok int64) fantasy.Agent {
738− agent := fantasy.NewAgent(a.smallModel.Model,
746+ return fantasy.NewAgent(m,
739− fantasy.WithSystemPrompt(string(titlePrompt)+"\n /no_think"),
747+ fantasy.WithSystemPrompt(string(p)+"\n /no_think"),
740− fantasy.WithMaxOutputTokens(maxOutput),
748+ fantasy.WithMaxOutputTokens(tok),
743− resp, err := agent.Stream(ctx, fantasy.AgentStreamCall{
752+ streamCall := fantasy.AgentStreamCall{
744− Prompt: fmt.Sprintf("Generate a concise title for the following content:\n\n%s\n <think>\n\n</think>", prompt),
753+ Prompt: fmt.Sprintf("Generate a concise title for the following content:\n\n%s\n <think>\n\n</think>", userPrompt),
745− PrepareStep: func(callContext context.Context, options fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
754+ PrepareStep: func(callCtx context.Context, opts fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
746− prepared.Messages = options.Messages
755+ prepared.Messages = opts.Messages
747 if a.systemPromptPrefix != "" {
756 if a.systemPromptPrefix != "" {
757+ prepared.Messages = append([]fantasy.Message{
748− prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(a.systemPromptPrefix)}, prepared.Messages...)
758+ fantasy.NewSystemMessage(a.systemPromptPrefix),
759+ }, prepared.Messages...)
750− return callContext, prepared, nil
761+ return callCtx, prepared, nil
754− slog.Error("error generating title", "err", err)
758− title := resp.Response.Content.Text()
765+ // Use the small model to generate the title.
766+ model := &a.smallModel
767+ agent := newAgent(model.Model, titlePrompt, maxOutputTokens)
768+ resp, err := agent.Stream(ctx, streamCall)
770+ // We successfully generated a title with the small model.
771+ slog.Info("generated title with small model")
773+ // It didn't work. Let's try with the big model.
774+ slog.Error("error generating title with small model; trying big model", "err", err)
775+ model = &a.largeModel
776+ agent = newAgent(model.Model, titlePrompt, maxOutputTokens)
777+ resp, err = agent.Stream(ctx, streamCall)
779+ slog.Info("generated title with large model")
781+ // Welp, the large model didn't work either.
782+ slog.Error("error generating title with large model", "err", err)
760− title = strings.ReplaceAll(title, "\n", " ")
786+ title := strings.ReplaceAll(resp.Response.Content.Text(), "\n", " ")
787+ slog.Info("generated title", "title", title)
762 // Remove thinking tags if present.
789 // Remove thinking tags if present.
763− if idx := strings.Index(title, "</think>"); idx > 0 {
790+ title = thinkTagRegex.ReplaceAllString(title, "")
764− title = title[idx+len("</think>"):]
767 title = strings.TrimSpace(title)
792 title = strings.TrimSpace(title)
768 if title == "" {
793 if title == "" {
769− slog.Warn("failed to generate title", "warn", "empty title")
794+ slog.Warn("empty title; using fallback")
770− return
795+ title = defaultSessionName
773 // Calculate usage and cost.
798 // Calculate usage and cost.
@@ -783,7 +808,11 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, prom
812+ slog.Error("no model available for cost calculation")
786− modelConfig := a.smallModel.CatwalkCfg
815+ modelConfig := model.CatwalkCfg
787 cost := modelConfig.CostPer1MInCached/1e6*float64(resp.TotalUsage.CacheCreationTokens) +
816 cost := modelConfig.CostPer1MInCached/1e6*float64(resp.TotalUsage.CacheCreationTokens) +
788 modelConfig.CostPer1MOutCached/1e6*float64(resp.TotalUsage.CacheReadTokens) +
817 modelConfig.CostPer1MOutCached/1e6*float64(resp.TotalUsage.CacheReadTokens) +
789 modelConfig.CostPer1MIn/1e6*float64(resp.TotalUsage.InputTokens) +
818 modelConfig.CostPer1MIn/1e6*float64(resp.TotalUsage.InputTokens) +
@@ -805,7 +834,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, prom
805 // concurrent session updates.
834 // concurrent session updates.
806 saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, title, promptTokens, completionTokens, cost)
835 saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, title, promptTokens, completionTokens, cost)
807 if saveErr != nil {
836 if saveErr != nil {
808− slog.Error("failed to save session title & usage", "error", saveErr)
837+ slog.Error("failed to save session title and usage", "error", saveErr)
@@ -947,6 +976,7 @@ func (a *sessionAgent) promptPrefix() string {
947 return a.systemPromptPrefix
976 return a.systemPromptPrefix
979+// XXX: this should be generalized to cover other subscription plans, like Copilot.
950func (a *sessionAgent) isClaudeCode() bool {
980func (a *sessionAgent) isClaudeCode() bool {
951 cfg := config.Get()
981 cfg := config.Get()
952 pc, ok := cfg.Providers.Get(a.largeModel.ModelCfg.Provider)
982 pc, ok := cfg.Providers.Get(a.largeModel.ModelCfg.Provider)