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:
+15
-14
@@ -4,6 +4,7 @@ import { isAuthorized } from './auth.js';
|
||||
import { TelegramClient, } 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';
|
||||
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
function contentToText(content) {
|
||||
return content
|
||||
@@ -11,14 +12,6 @@ function contentToText(content) {
|
||||
.map((block) => block.text)
|
||||
.join('');
|
||||
}
|
||||
function shortLabel(agent, index) {
|
||||
const id = String(agent.id);
|
||||
const tail = id.length > 12 ? id.slice(-12) : id;
|
||||
const cwd = agent.session?.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 {
|
||||
ctx;
|
||||
token;
|
||||
@@ -147,7 +140,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));
|
||||
@@ -183,13 +176,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 = {
|
||||
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, `选择要遥控的本机会话(共 ${agents.length} 个):`, undefined, keyboard);
|
||||
const body = [
|
||||
`选择要遥控的本机会话(共 ${agents.length} 个):`,
|
||||
'',
|
||||
...described.map((parts) => detailLines(parts)),
|
||||
'',
|
||||
'点下方按钮附着对应会话。',
|
||||
].join('\n');
|
||||
await this.client.sendMessage(chatId, body, undefined, keyboard);
|
||||
}
|
||||
async sendStatus(chatId) {
|
||||
const binding = this.bindings.get(String(chatId));
|
||||
|
||||
Vendored
+1
@@ -15,4 +15,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';
|
||||
|
||||
@@ -45,4 +45,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';
|
||||
|
||||
Vendored
+35
@@ -0,0 +1,35 @@
|
||||
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;
|
||||
}
|
||||
export declare function readSessionCwd(session: unknown): string | undefined;
|
||||
export declare function workspaceName(cwd: string | undefined): string | undefined;
|
||||
export declare function readSessionTitle(ctx: Context | undefined, session: unknown): string | undefined;
|
||||
export declare function describeAgent(agent: Agent, index: number, ctx?: Context): AgentLabelParts;
|
||||
/** Compact label for inline keyboard buttons (Telegram ≤64 chars). */
|
||||
export declare function buttonLabel(parts: AgentLabelParts): string;
|
||||
/** Multi-line detail block for the picker message body. */
|
||||
export declare function detailLines(parts: AgentLabelParts): string;
|
||||
/** Human label stored on bind / status (not length-capped as hard as buttons). */
|
||||
export declare function displayLabel(parts: AgentLabelParts): string;
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
const BUTTON_MAX = 64;
|
||||
export function readSessionCwd(session) {
|
||||
const s = session;
|
||||
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) {
|
||||
if (!cwd)
|
||||
return undefined;
|
||||
const parts = cwd.split(/[/\\]/).filter(Boolean);
|
||||
return parts.length > 0 ? parts[parts.length - 1] : undefined;
|
||||
}
|
||||
export function readSessionTitle(ctx, session) {
|
||||
if (!session)
|
||||
return undefined;
|
||||
try {
|
||||
const svc = ctx
|
||||
?.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?.sessionProjections;
|
||||
const projected = projections?.get?.(session, 'title');
|
||||
if (typeof projected === 'string' && projected.trim())
|
||||
return projected.trim();
|
||||
}
|
||||
catch {
|
||||
// optional service
|
||||
}
|
||||
const events = session.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, index, ctx) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
const bits = [];
|
||||
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;
|
||||
}
|
||||
+14
-19
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
+20
-3
@@ -44,10 +44,21 @@ function messageUpdate(chatId: number, userId: number, text: string, updateId =
|
||||
}
|
||||
}
|
||||
|
||||
function makeAgent(id: string, followups: UserMessage[]) {
|
||||
function makeAgent(id: string, followups: UserMessage[], opts?: {
|
||||
cwd?: string
|
||||
title?: string
|
||||
}) {
|
||||
const cwd = opts?.cwd ?? `/proj/${id}`
|
||||
const events = opts?.title
|
||||
? [{ type: 'session/title', data: { title: opts.title } }]
|
||||
: []
|
||||
return {
|
||||
id: SessionId(id),
|
||||
session: { meta: { cwd: `/proj/${id}` } },
|
||||
session: {
|
||||
header: { cwd },
|
||||
meta: { cwd },
|
||||
events,
|
||||
},
|
||||
followup(message: UserMessage) {
|
||||
followups.push(message)
|
||||
},
|
||||
@@ -118,7 +129,10 @@ test('/sessions with no live agents shows NO_LIVE', async () => {
|
||||
test('/sessions lists live agents with bind callbacks', async () => {
|
||||
const sent: SentMessage[] = []
|
||||
const followups: UserMessage[] = []
|
||||
const agent = makeAgent('live-aaa', followups)
|
||||
const agent = makeAgent('live-aaa', followups, {
|
||||
cwd: 'D:/gitData/demo-app',
|
||||
title: '演示会话',
|
||||
})
|
||||
const ctx = {
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
agents: {
|
||||
@@ -137,6 +151,9 @@ test('/sessions lists live agents with bind callbacks', async () => {
|
||||
})
|
||||
await bridge.processUpdate(messageUpdate(10, 1, '/sessions'))
|
||||
assert.match(sent[0]!.text, /选择要遥控/)
|
||||
assert.match(sent[0]!.text, /演示会话/)
|
||||
assert.match(sent[0]!.text, /D:\/gitData\/demo-app/)
|
||||
assert.match(sent[0]!.replyMarkup!.inline_keyboard![0]![0]!.text, /演示会话/)
|
||||
assert.ok(sent[0]!.replyMarkup?.inline_keyboard?.[0]?.[0]?.callback_data?.startsWith(BIND_CB_PREFIX))
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import {
|
||||
buttonLabel,
|
||||
describeAgent,
|
||||
detailLines,
|
||||
displayLabel,
|
||||
readSessionCwd,
|
||||
readSessionTitle,
|
||||
workspaceName,
|
||||
} from '../src/label.ts'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
test('workspaceName takes last path segment', () => {
|
||||
assert.equal(workspaceName('D:\\gitData\\dsh-telegram-channel'), 'dsh-telegram-channel')
|
||||
assert.equal(workspaceName('/home/me/proj/app'), 'app')
|
||||
})
|
||||
|
||||
test('readSessionCwd prefers header.cwd over meta.cwd', () => {
|
||||
assert.equal(readSessionCwd({ header: { cwd: '/real' }, meta: { cwd: '/old' } }), '/real')
|
||||
assert.equal(readSessionCwd({ meta: { cwd: '/legacy' } }), '/legacy')
|
||||
})
|
||||
|
||||
test('readSessionTitle uses service, then events', () => {
|
||||
const session = {
|
||||
header: { cwd: '/w' },
|
||||
events: [
|
||||
{ type: 'user/message', data: {} },
|
||||
{ type: 'session/title', data: { title: ' 修复登录 ' } },
|
||||
],
|
||||
}
|
||||
assert.equal(readSessionTitle(undefined, session), '修复登录')
|
||||
const ctx = {
|
||||
sessionTitle: { get: () => ({ title: '来自服务' }) },
|
||||
}
|
||||
assert.equal(readSessionTitle(ctx as any, session), '来自服务')
|
||||
})
|
||||
|
||||
test('describeAgent builds title + workspace + id tail', () => {
|
||||
const agent = {
|
||||
id: SessionId('0123456789abcdef-live'),
|
||||
session: {
|
||||
header: { cwd: 'D:/gitData/my-app' },
|
||||
events: [{ type: 'session/title', data: { title: '重构支付' } }],
|
||||
},
|
||||
}
|
||||
const parts = describeAgent(agent as any, 0)
|
||||
assert.equal(parts.title, '重构支付')
|
||||
assert.equal(parts.workspace, 'my-app')
|
||||
assert.match(detailLines(parts), /重构支付/)
|
||||
assert.match(detailLines(parts), /D:\/gitData\/my-app/)
|
||||
assert.match(buttonLabel(parts), /^1\. 重构支付/)
|
||||
assert.match(displayLabel(parts), /重构支付 · my-app/)
|
||||
})
|
||||
|
||||
test('buttonLabel stays within 64 chars', () => {
|
||||
const longTitle = '啊'.repeat(80)
|
||||
const label = buttonLabel({
|
||||
index: 0,
|
||||
title: longTitle,
|
||||
workspace: 'ws',
|
||||
idTail: 'abc',
|
||||
sessionId: 'x',
|
||||
})
|
||||
assert.ok([...label].length <= 64)
|
||||
})
|
||||
Reference in New Issue
Block a user