fix: show session title and workspace in /sessions picker

Read cwd from session.header (not only meta) and title from sessionTitle service or session/title events; list details in the message body and richer inline buttons.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
owo
2026-08-14 18:10:43 +08:00
co-authored by Cursor
parent 4bd3832858
commit c6125807f7
10 changed files with 383 additions and 36 deletions
+14 -19
View File
@@ -12,6 +12,7 @@ import {
} from './client.js'
import { BIND_CB_PREFIX, MSG, parseCommand } from './commands.js'
import { markdownToHtml, splitMessage } from './format.js'
import { buttonLabel, describeAgent, detailLines, displayLabel } from './label.js'
export interface TelegramBridgeOptions {
token: string
@@ -43,15 +44,6 @@ function contentToText(content: readonly ContentBlock[]): string {
.join('')
}
function shortLabel(agent: Agent, index: number): string {
const id = String(agent.id)
const tail = id.length > 12 ? id.slice(-12) : id
const cwd = (agent.session as { meta?: { cwd?: string } } | undefined)?.meta?.cwd
const cwdBit = cwd ? cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : ''
const base = cwdBit ? `${cwdBit} · ${tail}` : `session ${index + 1} · ${tail}`
return base.length > 60 ? `${base.slice(0, 57)}...` : base
}
export class TelegramBridge {
private readonly ctx: Context
private readonly token: string
@@ -188,7 +180,7 @@ export class TelegramBridge {
await this.client.sendMessage(chatId, MSG.GONE)
return
}
const label = shortLabel(agent, 0)
const label = displayLabel(describeAgent(agent, 0, this.ctx))
this.bindings.set(String(chatId), { chatId, sessionId: String(agent.id), label })
await this.client.answerCallbackQuery(cq.id, '已附着')
await this.client.sendMessage(chatId, MSG.BOUND(label))
@@ -223,18 +215,21 @@ export class TelegramBridge {
await this.client.sendMessage(chatId, MSG.NO_LIVE)
return
}
const described = agents.map((agent, i) => describeAgent(agent, i, this.ctx))
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: agents.map((agent, i) => {
const label = shortLabel(agent, i)
return [{ text: label, callback_data: `${BIND_CB_PREFIX}${String(agent.id)}` }]
}),
inline_keyboard: described.map((parts) => ([{
text: buttonLabel(parts),
callback_data: `${BIND_CB_PREFIX}${parts.sessionId}`,
}])),
}
await this.client.sendMessage(
chatId,
const body = [
`选择要遥控的本机会话(共 ${agents.length} 个):`,
undefined,
keyboard,
)
'',
...described.map((parts) => detailLines(parts)),
'',
'点下方按钮附着对应会话。',
].join('\n')
await this.client.sendMessage(chatId, body, undefined, keyboard)
}
private async sendStatus(chatId: number): Promise<void> {
+1
View File
@@ -61,4 +61,5 @@ export * from './format.js'
export * from './client.js'
export * from './auth.js'
export * from './commands.js'
export * from './label.js'
export * from './bridge.js'
+123
View File
@@ -0,0 +1,123 @@
import type { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
/** Loose session shape so tests and live Session both work. */
export interface SessionView {
header?: { cwd?: string }
meta?: { cwd?: string }
events?: ReadonlyArray<{ type?: string; data?: { title?: string } }>
}
export interface AgentLabelParts {
index: number
title?: string
cwd?: string
workspace?: string
idTail: string
sessionId: string
}
const BUTTON_MAX = 64
export function readSessionCwd(session: unknown): string | undefined {
const s = session as SessionView | undefined
const cwd = s?.header?.cwd ?? s?.meta?.cwd
if (typeof cwd !== 'string') return undefined
const trimmed = cwd.trim()
return trimmed.length > 0 ? trimmed : undefined
}
export function workspaceName(cwd: string | undefined): string | undefined {
if (!cwd) return undefined
const parts = cwd.split(/[/\\]/).filter(Boolean)
return parts.length > 0 ? parts[parts.length - 1] : undefined
}
export function readSessionTitle(ctx: Context | undefined, session: unknown): string | undefined {
if (!session) return undefined
try {
const svc = (ctx as { sessionTitle?: { get?: (s: unknown) => { title?: string } | undefined } } | undefined)
?.sessionTitle
const snap = svc?.get?.(session)
if (snap?.title && String(snap.title).trim()) return String(snap.title).trim()
} catch {
// optional service
}
try {
const projections = (ctx as {
sessionProjections?: { get?: (s: unknown, key: string) => unknown }
} | undefined)?.sessionProjections
const projected = projections?.get?.(session, 'title')
if (typeof projected === 'string' && projected.trim()) return projected.trim()
} catch {
// optional service
}
const events = (session as SessionView).events
if (Array.isArray(events)) {
for (let i = events.length - 1; i >= 0; i -= 1) {
const ev = events[i]
if (ev?.type === 'session/title' && typeof ev.data?.title === 'string') {
const t = ev.data.title.trim()
if (t) return t
}
}
}
return undefined
}
export function describeAgent(agent: Agent, index: number, ctx?: Context): AgentLabelParts {
const sessionId = String(agent.id)
const idTail = sessionId.length > 12 ? sessionId.slice(-12) : sessionId
const cwd = readSessionCwd(agent.session)
const title = readSessionTitle(ctx, agent.session)
return {
index,
title,
cwd,
workspace: workspaceName(cwd),
idTail,
sessionId,
}
}
/** Compact label for inline keyboard buttons (Telegram ≤64 chars). */
export function buttonLabel(parts: AgentLabelParts): string {
const n = parts.index + 1
const primary = parts.title || parts.workspace || `session ${n}`
let text = `${n}. ${primary}`
if (parts.title && parts.workspace) {
const withWs = `${n}. ${parts.title} · ${parts.workspace}`
if ([...withWs].length <= BUTTON_MAX) text = withWs
}
if ([...text].length <= BUTTON_MAX) return text
const chars = [...text]
return `${chars.slice(0, BUTTON_MAX - 1).join('')}…`
}
/** Multi-line detail block for the picker message body. */
export function detailLines(parts: AgentLabelParts): string {
const lines = [`${parts.index + 1}. ${parts.title ?? '(无标题)'}`]
if (parts.cwd) {
lines.push(` 工作区:${parts.cwd}`)
} else if (parts.workspace) {
lines.push(` 工作区:${parts.workspace}`)
} else {
lines.push(' 工作区:(未知)')
}
lines.push(` ID:…${parts.idTail}`)
return lines.join('\n')
}
/** Human label stored on bind / status (not length-capped as hard as buttons). */
export function displayLabel(parts: AgentLabelParts): string {
const bits: string[] = []
if (parts.title) bits.push(parts.title)
if (parts.workspace) bits.push(parts.workspace)
if (bits.length === 0) bits.push(`session ${parts.index + 1}`)
bits.push(`…${parts.idTail}`)
const text = bits.join(' · ')
return text.length > 120 ? `${text.slice(0, 117)}...` : text
}