Skip to content
Closed
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
30 changes: 30 additions & 0 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,36 @@ export namespace Server {
return c.json(msg)
},
)
.post(
"/session_generate_title",
describeRoute({
description: "Generate a concise title for a message",
responses: {
200: {
description: "Generated title",
content: {
"application/json": {
schema: resolver(z.object({
title: z.string(),
})),
},
},
},
},
}),
zValidator(
"json",
z.object({
text: z.string(),
providerID: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json")
const title = await Session.generateTitle(body.text, body.providerID)
return c.json({ title })
},
)
.post(
"/provider_list",
describeRoute({
Expand Down
58 changes: 58 additions & 0 deletions packages/opencode/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,64 @@ export namespace Session {
})
}

export async function generateTitle(userMessage: string, providerID: string): Promise<string> {
const log = Log.create({ service: "title-generator" })
const fallback = userMessage.split('\n')[0].slice(0, 40) + (userMessage.length > 40 ? '...' : '')
try {
let titleModelID: string | undefined
let titleProviderID: string | undefined
switch (providerID) {
case "anthropic":
titleModelID = "claude-3-5-haiku-20241022"
titleProviderID = "anthropic"
break
case "openai":
titleModelID = "gpt-4o-mini"
titleProviderID = "openai"
break
default:
}

if (!titleModelID || !titleProviderID) {
return fallback
}

try {
await Provider.getModel(titleProviderID, titleModelID)
} catch {
return fallback
}

const model = await Provider.getModel(titleProviderID, titleModelID)

const result = await generateText({
model: model.language,
messages: [
{
role: "system",
content: SystemPrompt.windowTitle()
},
{
role: "user",
content: userMessage
}],
maxTokens: 20,
temperature: 0,
})

const title = result.text?.trim() || fallback

// Ensure title is not too long
if (title.length > 40) {
return title.slice(0, 37) + '...'
}

return title
} catch (error) {
log.error("Failed to generate title", { error })
return fallback
}
}
export async function chat(input: {
sessionID: string
providerID: string
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/session/prompt/title-window.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Summarize this coding conversation and generate a concise title (under 50 characters) for this message. Focus on the main action or intent. nCapture the main task, key files, problems addressed, and current status. Use active verbs when possible. Omit articles and unnecessary words. Output only the title, nothing else.
5 changes: 5 additions & 0 deletions packages/opencode/src/session/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import PROMPT_ANTHROPIC from "./prompt/anthropic.txt"
import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
import PROMPT_SUMMARIZE from "./prompt/summarize.txt"
import PROMPT_TITLE from "./prompt/title.txt"
import PROMPT_TITLE_WINDOW from "./prompt/title-window.txt"

export namespace SystemPrompt {
export function provider(providerID: string) {
Expand Down Expand Up @@ -134,4 +135,8 @@ export namespace SystemPrompt {
return [PROMPT_TITLE]
}
}

export function windowTitle() {
return PROMPT_TITLE_WINDOW
}
}
25 changes: 25 additions & 0 deletions packages/tui/internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ type SendMsg struct {
Text string
Attachments []Attachment
}
type WindowTitleMsg struct {
Title string
}
type CompletionDialogTriggerdMsg struct {
InitialValue string
}
Expand Down Expand Up @@ -389,6 +392,28 @@ func (a *App) ListProviders(ctx context.Context) ([]client.ProviderInfo, error)
return providers.Providers, nil
}

func (a *App) GenerateWindowTitle(ctx context.Context, text string) (string, error) {
if a.Provider == nil {
return "", fmt.Errorf("no provider configured")
}

requestBody := client.PostSessionGenerateTitleJSONRequestBody{
Text: text,
ProviderID: a.Provider.Id,
}

response, err := a.Client.PostSessionGenerateTitleWithResponse(ctx, requestBody)
if err != nil {
return "", err
}

if response.StatusCode() != 200 || response.JSON200 == nil {
return "", fmt.Errorf("failed to generate title: status %d", response.StatusCode())
}

return response.JSON200.Title, nil
}

// func (a *App) loadCustomKeybinds() {
//
// }
55 changes: 54 additions & 1 deletion packages/tui/internal/tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,26 @@ type appModel struct {
leaderBinding *key.Binding
isLeaderSequence bool
toastManager *toast.ToastManager
lastSubmittedMessage string
wasProcessing bool
}

func formatWindowTitle(message string) string {
if message == "" {
return "opencode"
}

// Take first line only
lines := strings.Split(message, "\n")
title := strings.TrimSpace(lines[0])

// Truncate if too long
maxLen := 50
if len(title) > maxLen {
title = title[:maxLen-3] + "..."
}

return "opencode: " + title
}

func (a appModel) Init() tea.Cmd {
Expand All @@ -56,6 +76,9 @@ func (a appModel) Init() tea.Cmd {
cmds = append(cmds, a.completions.Init())
cmds = append(cmds, a.toastManager.Init())

// Set initial window title
cmds = append(cmds, tea.SetWindowTitle("opencode"))

// Check if we should show the init dialog
cmds = append(cmds, func() tea.Msg {
shouldShow := a.app.Info.Git && a.app.Info.Time.Initialized == nil
Expand Down Expand Up @@ -208,8 +231,23 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case app.SendMsg:
a.showCompletionDialog = false
a.lastSubmittedMessage = msg.Text
cmd := a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments)
cmds = append(cmds, cmd)

// Generate title asynchronously
cmds = append(cmds, func() tea.Msg {
title, err := a.app.GenerateWindowTitle(context.Background(), msg.Text)
if err != nil {
slog.Debug("Failed to generate window title", "error", err)
// Fall back to truncated message
title = formatWindowTitle(msg.Text)
} else {
// Prepend "opencode: " to AI-generated title
title = "opencode: " + title
}
return app.WindowTitleMsg{Title: title}
})
case dialog.CompletionDialogCloseMsg:
a.showCompletionDialog = false
case client.EventInstallationUpdated:
Expand Down Expand Up @@ -261,6 +299,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
a.app.Session = msg
a.app.Messages = messages
a.lastSubmittedMessage = ""
cmds = append(cmds, tea.SetWindowTitle("opencode"))
case app.WindowTitleMsg:
return a, tea.SetWindowTitle(msg.Title)
case app.ModelSelectedMsg:
a.app.Provider = &msg.Provider
a.app.Model = &msg.Model
Expand Down Expand Up @@ -308,6 +350,14 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
}

// Check if AI finished processing and reset title
isProcessing := a.app.IsBusy()
if a.wasProcessing && !isProcessing && a.lastSubmittedMessage != "" {
a.lastSubmittedMessage = ""
cmds = append(cmds, tea.SetWindowTitle("opencode"))
}
a.wasProcessing = isProcessing

return a, tea.Batch(cmds...)
}

Expand Down Expand Up @@ -415,7 +465,9 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
}
a.app.Session = &client.SessionInfo{}
a.app.Messages = []client.MessageInfo{}
a.lastSubmittedMessage = ""
cmds = append(cmds, util.CmdHandler(app.SessionClearedMsg{}))
cmds = append(cmds, tea.SetWindowTitle("opencode"))
case commands.SessionListCommand:
sessionDialog := dialog.NewSessionDialog(a.app)
a.modal = sessionDialog
Expand Down Expand Up @@ -443,7 +495,8 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
return a, nil
}
a.app.Cancel(context.Background(), a.app.Session.Id)
return a, nil
a.lastSubmittedMessage = ""
return a, tea.SetWindowTitle("opencode")
case commands.SessionCompactCommand:
if a.app.Session.Id == "" {
return a, nil
Expand Down
Loading