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
22 changes: 16 additions & 6 deletions adapters/common/__tests__/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,21 @@ describe('formatImHelp', () => {
const text = formatImHelp()
expect(text).toContain('/new')
expect(text).toContain('/projects')
expect(text).not.toContain('/sessions')
expect(text).not.toContain('/resume')
expect(text).toContain('/status')
expect(text).toContain('/clear')
expect(text).toContain('/stop')
expect(text).toContain('/help')
expect(text).toContain('项目列表')
expect(text).toContain('/allow <id>')
})

it('can include historical session commands for adapters that support them', () => {
const text = formatImHelp({ includeSessionCommands: true })
expect(text).toContain('/sessions')
expect(text).toContain('/resume')
})
})

describe('formatImStatus', () => {
Expand All @@ -133,12 +141,14 @@ describe('formatImStatus', () => {
},
})

expect(text).toContain('项目: claude-code-haha (main)')
expect(text).toContain('会话: abc12345…')
expect(text).toContain('模型: claude-sonnet')
expect(text).toContain('状态: 执行工具中 (Running tests)')
expect(text).toContain('审批: 1 个待确认')
expect(text).toContain('任务: 总计 4 · 进行中 2 · 待处理 1 · 已完成 1')
expect(text).toContain('当前状态面板')
expect(text).toContain('项目:claude-code-haha (main)')
expect(text).toContain('会话:abc12345…')
expect(text).toContain('模型:claude-sonnet')
expect(text).toContain('状态:正在执行工具 (Running tests)')
expect(text).toContain('审批:1 个待确认')
expect(text).toContain('任务:总计 4 · 进行中 2 · 待处理 1 · 已完成 1')
expect(text).toContain('/sessions')
})

it('returns a friendly empty-session message when nothing is active', () => {
Expand Down
27 changes: 27 additions & 0 deletions adapters/common/__tests__/http-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,33 @@ describe('AdapterHttpClient', () => {
expect(projects[0].projectName).toBe('my-app')
})

it('listSessions calls GET /api/sessions with filters', async () => {
const mockSessions = [{
id: 'session-123',
title: 'Fix bug',
createdAt: '2026-01-01T00:00:00.000Z',
modifiedAt: '2026-01-02T00:00:00.000Z',
messageCount: 4,
projectPath: '-home-user-my-app',
workDir: '/home/user/my-app',
workDirExists: true,
}]
globalThis.fetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({ sessions: mockSessions, total: 1 }), {
headers: { 'Content-Type': 'application/json' },
}))
) as any

const result = await client.listSessions({ project: '/home/user/my-app', limit: 10, offset: 20 })

expect(result.sessions[0]?.id).toBe('session-123')
const callUrl = new URL((globalThis.fetch as any).mock.calls[0][0])
expect(`${callUrl.origin}${callUrl.pathname}`).toBe('http://127.0.0.1:3456/api/sessions')
expect(callUrl.searchParams.get('project')).toBe('/home/user/my-app')
expect(callUrl.searchParams.get('limit')).toBe('10')
expect(callUrl.searchParams.get('offset')).toBe('20')
})

it('matchProject accepts an absolute local project path inside an allowed root without recent history', async () => {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-root-'))
const projectDir = fs.mkdtempSync(path.join(rootDir, 'project-'))
Expand Down
52 changes: 33 additions & 19 deletions adapters/common/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,15 @@ const IM_HELP_LINES = [
'/clear / 清空 — 清空当前会话上下文',
'/stop / 停止 — 停止当前生成',
'/help / 帮助 — 显示这份帮助',
'权限审批:/allow <id>、/always <id>、/deny <id>',
]

const IM_SESSION_HELP_LINES = [
'/sessions [项目] / 会话列表 — 查看历史会话',
'/resume <编号|sessionId> / 切换会话 — 切换到历史会话',
]

const IM_PERMISSION_HELP_LINE = '权限审批:/allow <id>、/always <id>、/deny <id>'

/** Split text into chunks that fit within a character limit, respecting paragraph/sentence boundaries. */
export function splitMessage(text: string, limit: number): string[] {
if (text.length <= limit) return [text]
Expand Down Expand Up @@ -159,45 +165,53 @@ export function escapeMarkdownV2(text: string): string {
return text.replace(/([_*\[\]()~`>#+\-=|{}.!\\])/g, '\\$1')
}

export function formatImHelp(): string {
return `可用命令:\n\n${IM_HELP_LINES.join('\n')}`
export function formatImHelp(options?: { includeSessionCommands?: boolean }): string {
const lines = options?.includeSessionCommands
? [...IM_HELP_LINES, ...IM_SESSION_HELP_LINES, IM_PERMISSION_HELP_LINE]
: [...IM_HELP_LINES, IM_PERMISSION_HELP_LINE]
return `可用命令:\n\n${lines.join('\n')}`
}

export function formatImStatus(summary: ImStatusSummary | null): string {
if (!summary?.sessionId) {
return '当前没有活动会话\n\n发送 /new 新建会话,或发送 /projects 选择项目。'
return '📍 当前没有活动会话\n\n可执行:`/new` 新建会话,或发送 `/projects` 选择项目。'
}

const lines = ['当前会话状态:']
const lines = ['📍 **当前状态面板**']

if (summary.projectName) {
lines.push(`项目: ${summary.projectName}${summary.branch ? ` (${summary.branch})` : ''}`)
lines.push(`项目${summary.projectName}${summary.branch ? ` (${summary.branch})` : ''}`)
} else if (summary.branch) {
lines.push(`分支: ${summary.branch}`)
lines.push(`分支${summary.branch}`)
}

lines.push(`会话: ${shortSessionId(summary.sessionId)}`)
lines.push(`会话${shortSessionId(summary.sessionId)}`)

if (summary.model) {
lines.push(`模型: ${summary.model}`)
lines.push(`模型${summary.model}`)
}

lines.push(`状态: ${formatAdapterChatState(summary.state, summary.verb)}`)
lines.push(`状态${formatAdapterChatState(summary.state, summary.verb)}`)

const pendingPermissionCount = summary.pendingPermissionCount ?? 0
if (pendingPermissionCount > 0) {
lines.push(`审批: ${pendingPermissionCount} 个待确认`)
}
lines.push(
pendingPermissionCount > 0
? `审批:${pendingPermissionCount} 个待确认`
: '审批:当前无待确认请求',
)

const taskCounts = summary.taskCounts
if (taskCounts && taskCounts.total > 0) {
const taskParts = [`总计 ${taskCounts.total}`]
if (taskCounts.inProgress > 0) taskParts.push(`进行中 ${taskCounts.inProgress}`)
if (taskCounts.pending > 0) taskParts.push(`待处理 ${taskCounts.pending}`)
if (taskCounts.completed > 0) taskParts.push(`已完成 ${taskCounts.completed}`)
lines.push(`任务: ${taskParts.join(' · ')}`)
lines.push(`任务:${taskParts.join(' · ')}`)
} else {
lines.push('任务:当前无任务')
}

lines.push('操作:`/clear` 清空上下文 · `/stop` 停止生成 · `/sessions` 查看历史会话')
return lines.join('\n')
}

Expand All @@ -208,16 +222,16 @@ function formatAdapterChatState(
const label = (() => {
switch (state) {
case 'thinking':
return '思考中'
return '正在理解问题'
case 'streaming':
return '生成中'
return '正在整理回复'
case 'tool_executing':
return '执行工具中'
return '正在执行工具'
case 'permission_pending':
return '等待权限确认'
return '等待你确认权限'
case 'idle':
default:
return '空闲'
return '空闲,等待新消息'
}
})()

Expand Down
32 changes: 32 additions & 0 deletions adapters/common/http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ export type GitInfo = {
changedFiles: number
}

export type SessionListItem = {
id: string
title: string
createdAt: string
modifiedAt: string
messageCount: number
projectPath: string
workDir: string | null
workDirExists: boolean
}

export type SessionTask = {
id: string
subject: string
Expand Down Expand Up @@ -88,6 +99,27 @@ export class AdapterHttpClient {
}
}

async listSessions(options?: {
project?: string
limit?: number
offset?: number
}): Promise<{ sessions: SessionListItem[]; total: number }> {
const { controller, timer } = this.createTimeoutController()
try {
const url = new URL(`${this.httpBaseUrl}/api/sessions`)
if (options?.project) url.searchParams.set('project', options.project)
if (options?.limit !== undefined) url.searchParams.set('limit', String(options.limit))
if (options?.offset !== undefined) url.searchParams.set('offset', String(options.offset))
const res = await fetch(url, { signal: controller.signal })
if (!res.ok) {
throw new Error(`Failed to list sessions: ${res.statusText}`)
}
return (await res.json()) as { sessions: SessionListItem[]; total: number }
} finally {
clearTimeout(timer)
}
}

/**
* Match a project by index (1-based) or fuzzy name from recent projects.
* Returns { project, ambiguous[] } — ambiguous is set when multiple projects match.
Expand Down
Loading
Loading