Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,28 @@ The plugin creates a default global config on first run.
// "model": "anthropic/claude-haiku-4-5",

// Update title every N idle events (1 = every time you pause)
"updateThreshold": 1
"updateThreshold": 1,

// Title format with placeholders:
// - {title} - AI-generated title based on conversation
// - {cwd} - Full current working directory path
// - {cwdTip} - Last folder name of cwd (e.g., "my-project")
// - {cwdTip:N} - Last N folder segments (e.g., {cwdTip:2} -> "github/my-project")
// - {cwdTip:N:sep} - Last N segments with custom separator
"titleFormat": "{title}"
}
```

### Example Formats

| Format | Example Output |
|--------|----------------|
| `{title}` | `Debugging API errors` |
| `[{cwdTip}] {title}` | `[my-project] Debugging API errors` |
| `[{cwdTip:2}] {title}` | `[github/my-project] Debugging API errors` |
| `[{cwdTip:2: - }] {title}` | `[github - my-project] Debugging API errors` |
| `{title} ({cwdTip})` | `Debugging API errors (my-project)` |

## License

MIT
72 changes: 65 additions & 7 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,49 @@ function cleanTitle(raw: string): string {
return cleaned
}

/**
* Placeholder values available for title formatting
*/
interface PlaceholderValues {
title: string
cwd: string
}

/**
* Resolve a cwdTip placeholder with optional parameters
* Formats:
* {cwdTip} - last folder name (default)
* {cwdTip:N} - last N folder segments, joined by "/"
* {cwdTip:N:sep} - last N folder segments, joined by custom separator
*/
function resolveCwdTip(cwd: string, depth: number, separator: string): string {
const segments = cwd.split('/').filter(s => s.length > 0)
const selected = segments.slice(-depth)
return selected.join(separator)
}

/**
* Apply placeholder replacements to title format
* Supports: {title}, {cwd}, {cwdTip}, {cwdTip:N}, {cwdTip:N:separator}
*/
function applyTitleFormat(format: string, values: PlaceholderValues): string {
let result = format
.replace(/\{title\}/g, values.title)
.replace(/\{cwd\}/g, values.cwd)
.replace(/\{cwdTip(?::(\d+)(?::([^}]+))?)?\}/g, (_match, depthStr, separator) => {
const depth = depthStr ? parseInt(depthStr, 10) : 1
const sep = separator ?? '/'
return resolveCwdTip(values.cwd, depth, sep)
})

// Truncate final result if too long
if (result.length > 100) {
result = result.substring(0, 97) + "..."
}

return result
}

/**
* Generate title from conversation context using AI
*/
Expand Down Expand Up @@ -351,10 +394,11 @@ async function updateSessionTitle(
client: OpenCodeClient,
sessionId: string,
logger: Logger,
config: ReturnType<typeof getConfig>
config: ReturnType<typeof getConfig>,
cwd: string
): Promise<void> {
try {
logger.info('update-title', 'Title update triggered', { sessionId })
logger.info('update-title', 'Title update triggered', { sessionId, cwd })

// Extract smart context
const turns = await extractSmartContext(client, sessionId, logger)
Expand All @@ -381,22 +425,32 @@ async function updateSessionTitle(
// Format context
const context = formatContextForTitle(turns)

// Generate title
const newTitle = await generateTitleFromContext(
// Generate title from AI
const generatedTitle = await generateTitleFromContext(
context,
config.model,
logger,
client
)

if (!newTitle) {
if (!generatedTitle) {
logger.warn('update-title', 'Title generation returned null', { sessionId })
return
}

// Apply title format with placeholders
const placeholderValues: PlaceholderValues = {
title: generatedTitle,
cwd: cwd
}

const newTitle = applyTitleFormat(config.titleFormat, placeholderValues)

logger.info('update-title', 'Updating session with new title', {
sessionId,
title: newTitle
generatedTitle,
titleFormat: config.titleFormat,
finalTitle: newTitle
})

// Update session
Expand Down Expand Up @@ -434,11 +488,15 @@ const SmartTitlePlugin: Plugin = async (ctx) => {
const logger = new Logger(config.debug)
const { client } = ctx

const cwd = ctx.directory || process.cwd()

logger.info('plugin', 'Smart Title plugin initialized', {
enabled: config.enabled,
debug: config.debug,
model: config.model,
updateThreshold: config.updateThreshold,
titleFormat: config.titleFormat,
cwd,
globalConfigFile: join(homedir(), ".config", "opencode", "smart-title.jsonc"),
projectConfigFile: ctx.directory ? join(ctx.directory, ".opencode", "smart-title.jsonc") : "N/A",
logDirectory: join(homedir(), ".config", "opencode", "logs", "smart-title")
Expand Down Expand Up @@ -485,7 +543,7 @@ const SmartTitlePlugin: Plugin = async (ctx) => {
})

// Fire and forget - don't block the event handler
updateSessionTitle(client, sessionId, logger, config).catch((error) => {
updateSessionTitle(client, sessionId, logger, config, cwd).catch((error) => {
logger.error('event', 'Title update failed', {
sessionId,
error: error.message,
Expand Down
29 changes: 25 additions & 4 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,22 @@ export interface PluginConfig {
debug: boolean
model?: string
updateThreshold: number
/**
* Title format with placeholders:
* - {title} - AI-generated title
* - {cwd} - Full current working directory path
* - {cwdTip} - Last folder name of cwd (e.g., "my-project")
* - {cwdTip:N} - Last N folder segments (e.g., {cwdTip:2} -> "github/my-project")
* - {cwdTip:N:sep} - Last N segments with custom separator (e.g., {cwdTip:2: - } -> "github - my-project")
*/
titleFormat: string
}

const defaultConfig: PluginConfig = {
enabled: true,
debug: false,
updateThreshold: 1
updateThreshold: 1,
titleFormat: '{title}'
}

const GLOBAL_CONFIG_DIR = join(homedir(), '.config', 'opencode')
Expand Down Expand Up @@ -94,7 +104,16 @@ function createDefaultConfig(): void {
// "model": "anthropic/claude-haiku-4-5",

// Update title every N idle events (default: 1)
"updateThreshold": 1
"updateThreshold": 1,

// Title format with placeholders:
// - {title} - AI-generated title based on conversation
// - {cwd} - Full current working directory path
// - {cwdTip} - Last folder name of cwd (e.g., "my-project")
// - {cwdTip:N} - Last N folder segments (e.g., {cwdTip:2} -> "github/my-project")
// - {cwdTip:N:sep} - Last N segments with custom separator (e.g., {cwdTip:2: - } -> "github - my-project")
// Example: "[{cwdTip}] {title}" produces "[my-project] Debugging API errors"
"titleFormat": "{title}"
}
`

Expand Down Expand Up @@ -137,7 +156,8 @@ export function getConfig(ctx?: PluginInput): PluginConfig {
enabled: globalConfig.enabled ?? config.enabled,
debug: globalConfig.debug ?? config.debug,
model: globalConfig.model ?? config.model,
updateThreshold: globalConfig.updateThreshold ?? config.updateThreshold
updateThreshold: globalConfig.updateThreshold ?? config.updateThreshold,
titleFormat: globalConfig.titleFormat ?? config.titleFormat
}
}
} else {
Expand All @@ -151,7 +171,8 @@ export function getConfig(ctx?: PluginInput): PluginConfig {
enabled: projectConfig.enabled ?? config.enabled,
debug: projectConfig.debug ?? config.debug,
model: projectConfig.model ?? config.model,
updateThreshold: projectConfig.updateThreshold ?? config.updateThreshold
updateThreshold: projectConfig.updateThreshold ?? config.updateThreshold,
titleFormat: projectConfig.titleFormat ?? config.titleFormat
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"dependencies": {
"@ai-sdk/openai-compatible": "^1.0.27",
"@tarquinen/opencode-auth-provider": "^0.1.7",
"ai": "^5.0.98",
"ai": "^6.0.43",
"jsonc-parser": "^3.3.1"
},
"devDependencies": {
Expand Down