When using non-Anthropic providers (Ollama, Gemini, Codex), the underlying call to queryHaiku for session title generation fails. Previously, this caused the catch block to return null, leaving the terminal tab permanently stuck on 'Claude Code'. Now, when the API call fails, we gracefully derive a title locally from the user's first message (first 7 words, sentence-cased), ensuring users still see a meaningful session title in their terminal tab.
153 lines
5.4 KiB
TypeScript
153 lines
5.4 KiB
TypeScript
/**
|
|
* Session title generation via Haiku.
|
|
*
|
|
* Standalone module with minimal dependencies so it can be imported from
|
|
* print.ts (SDK control request handler) without pulling in the React/chalk/
|
|
* git dependency chain that teleport.tsx carries.
|
|
*
|
|
* This is the single source of truth for AI-generated session titles across
|
|
* all surfaces. Previously there were separate Haiku title generators:
|
|
* - teleport.tsx generateTitleAndBranch (6-word title + branch for CCR)
|
|
* - rename/generateSessionName.ts (kebab-case name for /rename)
|
|
* Each remains for backwards compat; new callers should use this module.
|
|
*/
|
|
|
|
import { z } from 'zod/v4'
|
|
import { getIsNonInteractiveSession } from '../bootstrap/state.js'
|
|
import { logEvent } from '../services/analytics/index.js'
|
|
import { queryHaiku } from '../services/api/claude.js'
|
|
import type { Message } from '../types/message.js'
|
|
import { logForDebugging } from './debug.js'
|
|
import { safeParseJSON } from './json.js'
|
|
import { lazySchema } from './lazySchema.js'
|
|
import { extractTextContent } from './messages.js'
|
|
import { asSystemPrompt } from './systemPromptType.js'
|
|
|
|
const MAX_CONVERSATION_TEXT = 1000
|
|
|
|
/**
|
|
* Flatten a message array into a single text string for Haiku title input.
|
|
* Skips meta/non-human messages. Tail-slices to the last 1000 chars so
|
|
* recent context wins when the conversation is long.
|
|
*/
|
|
export function extractConversationText(messages: Message[]): string {
|
|
const parts: string[] = []
|
|
for (const msg of messages) {
|
|
if (msg.type !== 'user' && msg.type !== 'assistant') continue
|
|
if ('isMeta' in msg && msg.isMeta) continue
|
|
if ('origin' in msg && msg.origin && msg.origin.kind !== 'human') continue
|
|
const content = msg.message.content
|
|
if (typeof content === 'string') {
|
|
parts.push(content)
|
|
} else if (Array.isArray(content)) {
|
|
for (const block of content) {
|
|
if ('type' in block && block.type === 'text' && 'text' in block) {
|
|
parts.push(block.text as string)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const text = parts.join('\n')
|
|
return text.length > MAX_CONVERSATION_TEXT
|
|
? text.slice(-MAX_CONVERSATION_TEXT)
|
|
: text
|
|
}
|
|
|
|
const SESSION_TITLE_PROMPT = `Generate a concise, sentence-case title (3-7 words) that captures the main topic or goal of this coding session. The title should be clear enough that the user recognizes the session in a list. Use sentence case: capitalize only the first word and proper nouns.
|
|
|
|
Return JSON with a single "title" field.
|
|
|
|
Good examples:
|
|
{"title": "Fix login button on mobile"}
|
|
{"title": "Add OAuth authentication"}
|
|
{"title": "Debug failing CI tests"}
|
|
{"title": "Refactor API client error handling"}
|
|
|
|
Bad (too vague): {"title": "Code changes"}
|
|
Bad (too long): {"title": "Investigate and fix the issue where the login button does not respond on mobile devices"}
|
|
Bad (wrong case): {"title": "Fix Login Button On Mobile"}`
|
|
|
|
const titleSchema = lazySchema(() => z.object({ title: z.string() }))
|
|
|
|
/**
|
|
* Generate a sentence-case session title from a description or first message.
|
|
* Returns null on error or if Haiku returns an unparseable response.
|
|
*
|
|
* @param description - The user's first message or a description of the session
|
|
* @param signal - Abort signal for cancellation
|
|
*/
|
|
export async function generateSessionTitle(
|
|
description: string,
|
|
signal: AbortSignal,
|
|
): Promise<string | null> {
|
|
const trimmed = description.trim()
|
|
if (!trimmed) return null
|
|
|
|
try {
|
|
const result = await queryHaiku({
|
|
systemPrompt: asSystemPrompt([SESSION_TITLE_PROMPT]),
|
|
userPrompt: trimmed,
|
|
outputFormat: {
|
|
type: 'json_schema',
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
title: { type: 'string' },
|
|
},
|
|
required: ['title'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
signal,
|
|
options: {
|
|
querySource: 'generate_session_title',
|
|
agents: [],
|
|
// Reflect the actual session mode — this module is called from
|
|
// both the SDK print path (non-interactive) and the CCR remote
|
|
// session path via useRemoteSession (interactive).
|
|
isNonInteractiveSession: getIsNonInteractiveSession(),
|
|
hasAppendSystemPrompt: false,
|
|
mcpTools: [],
|
|
},
|
|
})
|
|
|
|
const text = extractTextContent(result.message.content)
|
|
|
|
const parsed = titleSchema().safeParse(safeParseJSON(text))
|
|
const title = parsed.success ? parsed.data.title.trim() || null : null
|
|
|
|
logEvent('tengu_session_title_generated', { success: title !== null })
|
|
|
|
return title
|
|
} catch (error) {
|
|
logForDebugging(`generateSessionTitle failed: ${error}`, {
|
|
level: 'error',
|
|
})
|
|
logEvent('tengu_session_title_generated', { success: false })
|
|
|
|
// Fallback: derive a title locally from the user's first message.
|
|
// This ensures 3P providers (Ollama, Gemini, OpenAI) still get
|
|
// meaningful terminal titles when the Haiku API call is unavailable.
|
|
return localFallbackTitle(trimmed)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fallback local title generator for when the Haiku API is unavailable
|
|
* (e.g. when using third-party providers without an Anthropic API key).
|
|
*/
|
|
function localFallbackTitle(text: string): string | null {
|
|
const words = text.split(/\s+/).slice(0, 7)
|
|
if (words.length === 0) return null
|
|
|
|
// Create a sentence-case string
|
|
let fallback = words.join(' ')
|
|
if (fallback.length > 50) {
|
|
fallback = fallback.substring(0, 49) + '…'
|
|
}
|
|
|
|
if (fallback.length <= 3) return null
|
|
|
|
return fallback.charAt(0).toUpperCase() + fallback.slice(1)
|
|
}
|