mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-11 01:59:07 +00:00
feat(cli): add sessions list command with --json and --limit flags (#5187)
* feat(cli): add sessions list command with --json and --limit flags Add a new qwen sessions CLI command with a list subcommand that enumerates stored sessions from the command line. The list subcommand supports --json for scriptable JSON Lines output and --limit for controlling page size. This is Phase 1 of the sessions management CLI namespace (see #4825). Follow-up phases will add show and rm subcommands. Implementation uses the existing SessionService API from core and follows the established yargs CommandModule pattern used by mcp, extensions, and channel subcommands. * fix(cli): address review comments on sessions list command - Sanitize ANSI escape sequences and strip \r\n from user-controllable fields - Use string-width for CJK-aware display width in truncate and padding - Compute PROMPT column width dynamically from terminal width - Add timezone indicator (LOCAL) to STARTED column header - Export column-width constants for test reuse - Validate --limit accepts only integer values - Guard hasMore hint behind items.length > 0 * fix(cli): remove sanitize from JSON output, add cwd field, add truncation tests - Remove destructive sanitize() from toJsonItem() — JSON.stringify already handles control characters, and JSON is a machine-consumption mode where data integrity matters more than terminal safety. - Add cwd field to JSON output so downstream tools can identify project dir. - Add test coverage for truncation with long ASCII and CJK strings. * fix(cli): harden sanitize, add C0 stripping, fix timezone/time column, hasMore via stderr - Switch formatTime to UTC methods so human and JSON output agree; remove (LOCAL) from STARTED column header. - Wrap time column in sanitize+truncate so the isNaN fallback path cannot inject raw strings into stdout. - Expand sanitize() to strip C0 control chars (BELL, BS, etc.) and C1 controls (0x7F-0x9F) in addition to \r \n \t. - Emit hasMore hint via stderr in --json mode so the stdout JSON Lines stream stays clean for jq/JSON.parse pipelines. - Add test coverage: sanitize (CR/LF, TAB, ANSI, BELL/BS, printable passthrough), formatTime isNaN fallback, hasMore via stderr in JSON mode, no hasMore in JSON when false/empty. --------- Co-authored-by: 俊良 <zzj542558@alibaba-inc.com>
This commit is contained in:
parent
a335f9cdfd
commit
14e6ae8c2b
7 changed files with 905 additions and 2 deletions
70
packages/cli/src/commands/sessions.test.ts
Normal file
70
packages/cli/src/commands/sessions.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
vi.mock('./sessions/list.js', () => ({
|
||||
listCommand: {
|
||||
command: 'list',
|
||||
describe: 'List sessions',
|
||||
},
|
||||
}));
|
||||
|
||||
import { sessionsCommand } from './sessions.js';
|
||||
import { type Argv } from 'yargs';
|
||||
import yargs from 'yargs';
|
||||
|
||||
describe('sessions command', () => {
|
||||
it('should have correct command definition', () => {
|
||||
expect(sessionsCommand.command).toBe('sessions');
|
||||
expect(sessionsCommand.describe).toBe('Manage Qwen Code sessions');
|
||||
expect(typeof sessionsCommand.builder).toBe('function');
|
||||
expect(typeof sessionsCommand.handler).toBe('function');
|
||||
});
|
||||
|
||||
it('should not inherit global flags', async () => {
|
||||
const yargsInstance = yargs();
|
||||
const builder = sessionsCommand.builder;
|
||||
if (typeof builder !== 'function') {
|
||||
throw new Error('sessions command builder must be a function');
|
||||
}
|
||||
const builtYargs = await builder(yargsInstance);
|
||||
// getOptions() exists at runtime but is not in @types/yargs.
|
||||
// mcp.test.ts uses the same pattern and is excluded from typecheck.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const options = (builtYargs as any).getOptions();
|
||||
|
||||
// Should have exactly 1 option (help flag)
|
||||
expect(Object.keys(options.key).length).toBe(1);
|
||||
expect(options.key).toHaveProperty('help');
|
||||
});
|
||||
|
||||
it('should register list subcommand', () => {
|
||||
const mockYargs = {
|
||||
command: vi.fn().mockReturnThis(),
|
||||
demandCommand: vi.fn().mockReturnThis(),
|
||||
version: vi.fn().mockReturnThis(),
|
||||
};
|
||||
|
||||
const builder = sessionsCommand.builder;
|
||||
if (typeof builder !== 'function') {
|
||||
throw new Error('sessions command builder must be a function');
|
||||
}
|
||||
builder(mockYargs as unknown as Argv);
|
||||
|
||||
expect(mockYargs.command).toHaveBeenCalledTimes(1);
|
||||
|
||||
const commandCalls = mockYargs.command.mock.calls;
|
||||
const commandNames = commandCalls.map((call) => call[0].command);
|
||||
|
||||
expect(commandNames).toContain('list');
|
||||
|
||||
expect(mockYargs.demandCommand).toHaveBeenCalledWith(
|
||||
1,
|
||||
'You need at least one command before continuing.',
|
||||
);
|
||||
});
|
||||
});
|
||||
21
packages/cli/src/commands/sessions.ts
Normal file
21
packages/cli/src/commands/sessions.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule, Argv } from 'yargs';
|
||||
import { listCommand } from './sessions/list.js';
|
||||
|
||||
export const sessionsCommand: CommandModule = {
|
||||
command: 'sessions',
|
||||
describe: 'Manage Qwen Code sessions',
|
||||
builder: (yargs: Argv) =>
|
||||
yargs
|
||||
.command(listCommand)
|
||||
.demandCommand(1, 'You need at least one command before continuing.')
|
||||
.version(false),
|
||||
// demandCommand(1) ensures a subcommand is always required;
|
||||
// yargs automatically shows help when none is provided.
|
||||
handler: () => {},
|
||||
};
|
||||
74
packages/cli/src/commands/sessions/common.test.ts
Normal file
74
packages/cli/src/commands/sessions/common.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
|
||||
|
||||
const mockLoadSettings = vi.hoisted(() => vi.fn());
|
||||
const mockSetRuntimeBaseDir = vi.hoisted(() => vi.fn());
|
||||
const mockSessionServiceConstructor = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../config/settings.js', () => ({
|
||||
loadSettings: mockLoadSettings,
|
||||
}));
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', () => ({
|
||||
Storage: {
|
||||
setRuntimeBaseDir: mockSetRuntimeBaseDir,
|
||||
},
|
||||
SessionService: mockSessionServiceConstructor,
|
||||
}));
|
||||
|
||||
import { initSessionService } from './common.js';
|
||||
|
||||
const mockedLoadSettings = mockLoadSettings as Mock;
|
||||
const mockedSetRuntimeBaseDir = mockSetRuntimeBaseDir as Mock;
|
||||
|
||||
describe('common', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should call Storage.setRuntimeBaseDir with correct args', () => {
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
merged: { advanced: { runtimeOutputDir: '/custom/runtime' } },
|
||||
});
|
||||
mockSessionServiceConstructor.mockReturnValue({});
|
||||
|
||||
initSessionService();
|
||||
|
||||
expect(mockedSetRuntimeBaseDir).toHaveBeenCalledWith(
|
||||
'/custom/runtime',
|
||||
process.cwd(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass undefined when runtimeOutputDir is not configured', () => {
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
merged: { advanced: {} },
|
||||
});
|
||||
mockSessionServiceConstructor.mockReturnValue({});
|
||||
|
||||
initSessionService();
|
||||
|
||||
expect(mockedSetRuntimeBaseDir).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
process.cwd(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return a SessionService instance', () => {
|
||||
const mockInstance = { listSessions: vi.fn() };
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
merged: { advanced: {} },
|
||||
});
|
||||
mockSessionServiceConstructor.mockReturnValue(mockInstance);
|
||||
|
||||
const result = initSessionService();
|
||||
|
||||
expect(result).toBe(mockInstance);
|
||||
expect(mockSessionServiceConstructor).toHaveBeenCalledWith(process.cwd());
|
||||
});
|
||||
});
|
||||
17
packages/cli/src/commands/sessions/common.ts
Normal file
17
packages/cli/src/commands/sessions/common.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Storage, SessionService } from '@qwen-code/qwen-code-core';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
|
||||
export function initSessionService(): SessionService {
|
||||
const settings = loadSettings();
|
||||
Storage.setRuntimeBaseDir(
|
||||
settings.merged.advanced?.runtimeOutputDir,
|
||||
process.cwd(),
|
||||
);
|
||||
return new SessionService(process.cwd());
|
||||
}
|
||||
494
packages/cli/src/commands/sessions/list.test.ts
Normal file
494
packages/cli/src/commands/sessions/list.test.ts
Normal file
|
|
@ -0,0 +1,494 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { handleList, SESSION_COL, TIME_COL, TITLE_COL } from './list.js';
|
||||
|
||||
const mockWriteStdoutLine = vi.hoisted(() => vi.fn());
|
||||
const mockWriteStderrLine = vi.hoisted(() => vi.fn());
|
||||
const mockListSessions = vi.hoisted(() => vi.fn());
|
||||
const mockInitSessionService = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../utils/stdioHelpers.js', () => ({
|
||||
writeStdoutLine: mockWriteStdoutLine,
|
||||
writeStderrLine: mockWriteStderrLine,
|
||||
}));
|
||||
|
||||
vi.mock('../../config/settings.js', () => ({
|
||||
loadSettings: vi.fn(() => ({
|
||||
merged: { advanced: {} },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('./common.js', () => ({
|
||||
initSessionService: mockInitSessionService,
|
||||
}));
|
||||
|
||||
const sampleSession = {
|
||||
sessionId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||
cwd: '/Users/test/project',
|
||||
startTime: '2026-06-15T10:30:00.000Z',
|
||||
mtime: 1718447400000,
|
||||
prompt: '帮我写一个 React 组件',
|
||||
gitBranch: 'main',
|
||||
filePath: '/path/to/chats/a1b2c3d4.jsonl',
|
||||
customTitle: 'React 组件开发',
|
||||
titleSource: 'auto',
|
||||
};
|
||||
|
||||
describe('sessions list command', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
||||
mockInitSessionService.mockReturnValue({
|
||||
listSessions: mockListSessions,
|
||||
});
|
||||
});
|
||||
|
||||
it('should display message when no sessions found', async () => {
|
||||
mockListSessions.mockResolvedValue({ items: [], hasMore: false });
|
||||
|
||||
await handleList({});
|
||||
|
||||
expect(mockWriteStdoutLine).toHaveBeenCalledWith('No sessions found.');
|
||||
});
|
||||
|
||||
it('should display sessions in human-readable table format', async () => {
|
||||
mockListSessions.mockResolvedValue({
|
||||
items: [sampleSession],
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
await handleList({});
|
||||
|
||||
const calls = mockWriteStdoutLine.mock.calls.map((c) => c[0]);
|
||||
expect(calls.some((c) => c.includes('SESSION ID'))).toBe(true);
|
||||
expect(calls.some((c) => c.includes('STARTED'))).toBe(true);
|
||||
expect(calls.some((c) => c.includes('TITLE'))).toBe(true);
|
||||
expect(calls.some((c) => c.includes('BRANCH'))).toBe(true);
|
||||
expect(calls.some((c) => c.includes('PROMPT'))).toBe(true);
|
||||
expect(
|
||||
calls.some((c) => c.includes('a1b2c3d4') && c.includes('React 组件开发')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should display dash for missing git branch', async () => {
|
||||
mockListSessions.mockResolvedValue({
|
||||
items: [{ ...sampleSession, gitBranch: undefined }],
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
await handleList({});
|
||||
|
||||
const calls = mockWriteStdoutLine.mock.calls.map((c) => c[0]);
|
||||
const dataLine = calls.find(
|
||||
(c) => c.includes('a1b2c3d4') && !c.includes('SESSION ID'),
|
||||
);
|
||||
expect(dataLine).toBeDefined();
|
||||
expect(dataLine).toContain('-');
|
||||
});
|
||||
|
||||
it('should fall back to prompt when customTitle is missing', async () => {
|
||||
mockListSessions.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
...sampleSession,
|
||||
customTitle: undefined,
|
||||
prompt: '你好',
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
await handleList({});
|
||||
|
||||
const calls = mockWriteStdoutLine.mock.calls.map((c) => c[0]);
|
||||
const dataLine = calls.find(
|
||||
(c) => c.includes('a1b2c3d4') && !c.includes('SESSION ID'),
|
||||
);
|
||||
expect(dataLine).toBeDefined();
|
||||
expect(dataLine).toContain('你好');
|
||||
});
|
||||
|
||||
it('should not fall back to prompt when customTitle is empty string', async () => {
|
||||
mockListSessions.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
...sampleSession,
|
||||
customTitle: '',
|
||||
prompt: '你好',
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
await handleList({});
|
||||
|
||||
// customTitle '' is a valid value — should not fall back to prompt.
|
||||
// TITLE column starts after SESSION_COL + 1 + TIME_COL + 1.
|
||||
const calls = mockWriteStdoutLine.mock.calls.map((c) => c[0]);
|
||||
const dataLine = calls.find(
|
||||
(c) => c.includes('a1b2c3d4') && !c.includes('SESSION ID'),
|
||||
);
|
||||
expect(dataLine).toBeDefined();
|
||||
const titleStart = SESSION_COL + 1 + TIME_COL + 1;
|
||||
const titleCol = dataLine!.slice(titleStart, titleStart + TITLE_COL);
|
||||
expect(titleCol.trim()).toBe('');
|
||||
});
|
||||
|
||||
it('should output JSON Lines format when --json is set', async () => {
|
||||
mockListSessions.mockResolvedValue({
|
||||
items: [sampleSession],
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
await handleList({ json: true });
|
||||
|
||||
const calls = mockWriteStdoutLine.mock.calls;
|
||||
const jsonLines = calls.filter(
|
||||
(c) => c[0] !== undefined && c[0].trim().startsWith('{'),
|
||||
);
|
||||
expect(jsonLines.length).toBe(1);
|
||||
|
||||
const parsed = JSON.parse(jsonLines[0][0]);
|
||||
expect(parsed.sessionId).toBe(sampleSession.sessionId);
|
||||
expect(parsed.startTime).toBe(sampleSession.startTime);
|
||||
expect(parsed.mtime).toBe(sampleSession.mtime);
|
||||
expect(parsed.prompt).toBe(sampleSession.prompt);
|
||||
expect(parsed.gitBranch).toBe('main');
|
||||
expect(parsed.customTitle).toBe('React 组件开发');
|
||||
expect(parsed.titleSource).toBe('auto');
|
||||
expect(parsed.filePath).toBe(sampleSession.filePath);
|
||||
expect(parsed.cwd).toBe(sampleSession.cwd);
|
||||
});
|
||||
|
||||
it('should output gitBranch as null in JSON when undefined', async () => {
|
||||
mockListSessions.mockResolvedValue({
|
||||
items: [{ ...sampleSession, gitBranch: undefined }],
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
await handleList({ json: true });
|
||||
|
||||
const calls = mockWriteStdoutLine.mock.calls;
|
||||
const jsonLines = calls.filter(
|
||||
(c) => c[0] !== undefined && c[0].trim().startsWith('{'),
|
||||
);
|
||||
expect(jsonLines.length).toBe(1);
|
||||
|
||||
const parsed = JSON.parse(jsonLines[0][0]);
|
||||
expect(parsed.gitBranch).toBeNull();
|
||||
});
|
||||
|
||||
it('should pass limit option to listSessions', async () => {
|
||||
mockListSessions.mockResolvedValue({ items: [], hasMore: false });
|
||||
|
||||
await handleList({ limit: 10 });
|
||||
|
||||
expect(mockListSessions).toHaveBeenCalledWith({
|
||||
size: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('should default limit to 20', async () => {
|
||||
mockListSessions.mockResolvedValue({ items: [], hasMore: false });
|
||||
|
||||
await handleList({});
|
||||
|
||||
expect(mockListSessions).toHaveBeenCalledWith({
|
||||
size: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('should yield JSON without header for multiple sessions', async () => {
|
||||
mockListSessions.mockResolvedValue({
|
||||
items: [
|
||||
sampleSession,
|
||||
{
|
||||
...sampleSession,
|
||||
sessionId: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
await handleList({ json: true });
|
||||
|
||||
const calls = mockWriteStdoutLine.mock.calls;
|
||||
const jsonLines = calls.filter(
|
||||
(c) => c[0] !== undefined && c[0].trim().startsWith('{'),
|
||||
);
|
||||
expect(jsonLines.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should show hasMore hint when there are more sessions', async () => {
|
||||
mockListSessions.mockResolvedValue({
|
||||
items: [sampleSession],
|
||||
hasMore: true,
|
||||
});
|
||||
|
||||
await handleList({});
|
||||
|
||||
expect(mockWriteStdoutLine).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Use --limit to show more'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not show hasMore hint when hasMore is false', async () => {
|
||||
mockListSessions.mockResolvedValue({
|
||||
items: [sampleSession],
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
await handleList({});
|
||||
|
||||
const calls = mockWriteStdoutLine.mock.calls.map((c) => c[0]);
|
||||
expect(calls.some((c) => c.includes('Use --limit to show more'))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not show hasMore hint when items is empty', async () => {
|
||||
mockListSessions.mockResolvedValue({
|
||||
items: [],
|
||||
hasMore: true,
|
||||
});
|
||||
|
||||
await handleList({});
|
||||
|
||||
const calls = mockWriteStdoutLine.mock.calls.map((c) => c[0]);
|
||||
expect(calls.some((c) => c.includes('Use --limit to show more'))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should truncate long prompt with ellipsis', async () => {
|
||||
const longPrompt = 'A'.repeat(100);
|
||||
mockListSessions.mockResolvedValue({
|
||||
items: [{ ...sampleSession, customTitle: undefined, prompt: longPrompt }],
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
await handleList({});
|
||||
|
||||
const calls = mockWriteStdoutLine.mock.calls.map((c) => c[0]);
|
||||
const dataLine = calls.find(
|
||||
(c) => c.includes('a1b2c3d4') && !c.includes('SESSION ID'),
|
||||
);
|
||||
expect(dataLine).toBeDefined();
|
||||
// Truncated output should contain ellipsis
|
||||
expect(dataLine).toContain('...');
|
||||
// Full original string must not appear
|
||||
expect(dataLine).not.toContain(longPrompt);
|
||||
});
|
||||
|
||||
it('should truncate CJK characters correctly', async () => {
|
||||
const longCjk = '这是一个非常非常长的中文测试标题文本内容'.repeat(5);
|
||||
mockListSessions.mockResolvedValue({
|
||||
items: [{ ...sampleSession, customTitle: undefined, prompt: longCjk }],
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
await handleList({});
|
||||
|
||||
const calls = mockWriteStdoutLine.mock.calls.map((c) => c[0]);
|
||||
const dataLine = calls.find(
|
||||
(c) => c.includes('a1b2c3d4') && !c.includes('SESSION ID'),
|
||||
);
|
||||
expect(dataLine).toBeDefined();
|
||||
// Full CJK string must not appear (truncated by display width)
|
||||
expect(dataLine).not.toContain(longCjk);
|
||||
});
|
||||
|
||||
// --- sanitize() tests ---
|
||||
|
||||
it('should strip CR, LF, and TAB from prompt in human output', async () => {
|
||||
mockListSessions.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
...sampleSession,
|
||||
customTitle: undefined,
|
||||
prompt: 'hello\r\n\tworld',
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
await handleList({});
|
||||
|
||||
const calls = mockWriteStdoutLine.mock.calls.map((c) => c[0]);
|
||||
const dataLine = calls.find(
|
||||
(c) => c.includes('a1b2c3d4') && !c.includes('SESSION ID'),
|
||||
);
|
||||
expect(dataLine).toBeDefined();
|
||||
// CR, LF, and TAB must all be stripped
|
||||
expect(dataLine).toContain('helloworld');
|
||||
expect(dataLine).not.toContain('\r');
|
||||
expect(dataLine).not.toContain('\n');
|
||||
expect(dataLine).not.toContain('\t');
|
||||
});
|
||||
|
||||
it('should escape ANSI escape sequences in human output', async () => {
|
||||
// After escapeAnsiCtrlCodes, the ESC byte \x1b becomes the 6-char
|
||||
// literal "", so keep the prompt short enough to fit the column.
|
||||
const prompt = '\x1b[31mRED\x1b[0m';
|
||||
mockListSessions.mockResolvedValue({
|
||||
items: [{ ...sampleSession, customTitle: undefined, prompt }],
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
await handleList({});
|
||||
|
||||
const calls = mockWriteStdoutLine.mock.calls.map((c) => c[0]);
|
||||
const dataLine = calls.find(
|
||||
(c) => c.includes('a1b2c3d4') && !c.includes('SESSION ID'),
|
||||
);
|
||||
expect(dataLine).toBeDefined();
|
||||
// Raw ANSI ESC byte must not appear (escapeAnsiCtrlCodes replaces it
|
||||
// with a literal escape sequence).
|
||||
expect(dataLine).not.toContain('\x1b');
|
||||
// The visible text should remain.
|
||||
expect(dataLine).toContain('RED');
|
||||
});
|
||||
|
||||
it('should strip bell and backspace C0 control characters', async () => {
|
||||
const prompt = 'ab\x07c\x08d';
|
||||
mockListSessions.mockResolvedValue({
|
||||
items: [{ ...sampleSession, customTitle: undefined, prompt }],
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
await handleList({});
|
||||
|
||||
const calls = mockWriteStdoutLine.mock.calls.map((c) => c[0]);
|
||||
const dataLine = calls.find(
|
||||
(c) => c.includes('a1b2c3d4') && !c.includes('SESSION ID'),
|
||||
);
|
||||
expect(dataLine).toBeDefined();
|
||||
// C0 controls must be stripped, alphabetic chars remain
|
||||
expect(dataLine).toContain('abcd');
|
||||
});
|
||||
|
||||
it('should not strip printable text in sanitize', async () => {
|
||||
mockListSessions.mockResolvedValue({
|
||||
items: [
|
||||
{ ...sampleSession, customTitle: undefined, prompt: 'Hello World 123' },
|
||||
],
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
await handleList({});
|
||||
|
||||
const calls = mockWriteStdoutLine.mock.calls.map((c) => c[0]);
|
||||
const dataLine = calls.find(
|
||||
(c) => c.includes('a1b2c3d4') && !c.includes('SESSION ID'),
|
||||
);
|
||||
expect(dataLine).toBeDefined();
|
||||
expect(dataLine).toContain('Hello World 123');
|
||||
});
|
||||
|
||||
it('should sanitize and truncate the time column', async () => {
|
||||
// An invalid startTime hits the isNaN fallback in formatTime, returning
|
||||
// the raw string. sanitize + truncate must prevent raw injection there.
|
||||
mockListSessions.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
...sampleSession,
|
||||
startTime: 'not-a-date\r\n\x1b[31mEVIL\x1b[0m',
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
await handleList({});
|
||||
|
||||
const calls = mockWriteStdoutLine.mock.calls.map((c) => c[0]);
|
||||
const dataLine = calls.find(
|
||||
(c) => c.includes('a1b2c3d4') && !c.includes('SESSION ID'),
|
||||
);
|
||||
expect(dataLine).toBeDefined();
|
||||
// Raw evil sequences must be gone
|
||||
expect(dataLine).not.toContain('\r');
|
||||
expect(dataLine).not.toContain('\n');
|
||||
expect(dataLine).not.toContain('\x1b');
|
||||
expect(dataLine).not.toContain('EVIL');
|
||||
// The sanitized result is 'not-a-date[31mEVIL[0m' — without the ESC byte,
|
||||
// and truncated. The key point is the raw escape is gone.
|
||||
});
|
||||
|
||||
// --- JSON mode hasMore tests ---
|
||||
|
||||
it('should emit hasMore hint to stderr in JSON mode', async () => {
|
||||
mockListSessions.mockResolvedValue({
|
||||
items: [sampleSession],
|
||||
hasMore: true,
|
||||
});
|
||||
|
||||
await handleList({ json: true });
|
||||
|
||||
// Hint must go to stderr, not stdout
|
||||
const stdoutCalls = mockWriteStdoutLine.mock.calls.map((c) => c[0]);
|
||||
expect(stdoutCalls.some((c) => c.includes('Use --limit'))).toBe(false);
|
||||
|
||||
expect(mockWriteStderrLine).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Use --limit to show more'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not emit hasMore hint to stderr when hasMore is false in JSON mode', async () => {
|
||||
mockListSessions.mockResolvedValue({
|
||||
items: [sampleSession],
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
await handleList({ json: true });
|
||||
|
||||
const stderrCalls = mockWriteStderrLine.mock.calls.map((c) => c[0]);
|
||||
expect(stderrCalls.some((c) => c.includes('Use --limit'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should not emit hasMore hint to stderr when items is empty in JSON mode', async () => {
|
||||
mockListSessions.mockResolvedValue({
|
||||
items: [],
|
||||
hasMore: true,
|
||||
});
|
||||
|
||||
await handleList({ json: true });
|
||||
|
||||
const stderrCalls = mockWriteStderrLine.mock.calls.map((c) => c[0]);
|
||||
expect(stderrCalls.some((c) => c.includes('Use --limit'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle initSessionService failure', async () => {
|
||||
mockInitSessionService.mockImplementation(() => {
|
||||
throw new Error('settings not found');
|
||||
});
|
||||
|
||||
await handleList({});
|
||||
|
||||
expect(mockWriteStderrLine).toHaveBeenCalledWith(
|
||||
expect.stringContaining('initialize session service'),
|
||||
);
|
||||
expect(mockWriteStderrLine).toHaveBeenCalledWith(
|
||||
expect.stringContaining('settings not found'),
|
||||
);
|
||||
expect(process.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should handle listSessions failure', async () => {
|
||||
mockListSessions.mockRejectedValue(new Error('disk full'));
|
||||
|
||||
await handleList({});
|
||||
|
||||
expect(mockWriteStderrLine).toHaveBeenCalledWith(
|
||||
expect.stringContaining('failed to list sessions'),
|
||||
);
|
||||
expect(mockWriteStderrLine).toHaveBeenCalledWith(
|
||||
expect.stringContaining('disk full'),
|
||||
);
|
||||
expect(process.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
223
packages/cli/src/commands/sessions/list.ts
Normal file
223
packages/cli/src/commands/sessions/list.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule, Argv } from 'yargs';
|
||||
import type {
|
||||
SessionService,
|
||||
SessionListItem,
|
||||
ListSessionsResult,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import stringWidth from 'string-width';
|
||||
import { escapeAnsiCtrlCodes } from '../../ui/utils/textUtils.js';
|
||||
import { initSessionService } from './common.js';
|
||||
import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js';
|
||||
|
||||
/** Fixed column widths for the human-readable table (exported for tests). */
|
||||
export const SESSION_COL = 38;
|
||||
export const TIME_COL = 16;
|
||||
export const TITLE_COL = 24;
|
||||
export const BRANCH_COL = 12;
|
||||
|
||||
/**
|
||||
* Format an ISO 8601 timestamp to a UTC short form: YYYY-MM-DD HH:MM.
|
||||
* Uses UTC methods so the human output matches the raw data in JSON.
|
||||
*/
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a user-controllable string for terminal output:
|
||||
* 1. Strip \r, \n, and \t to prevent carriage-return / log-injection
|
||||
* attacks and to keep table columns aligned.
|
||||
* 2. Escape ANSI escape sequences that could manipulate the terminal.
|
||||
* 3. Strip remaining C0 control characters (0x00-0x08, 0x0b, 0x0c,
|
||||
* 0x0e-0x1f) and C1 controls (0x7f-0x9f) that can cause disruptive
|
||||
* terminal behaviour (bell, backspace, cursor movement, etc.).
|
||||
*/
|
||||
function sanitize(value: string): string {
|
||||
// Strip \r, \n, \t — these either inject fake newlines or misalign columns.
|
||||
const stripped = value.replace(/[\r\n\t]/g, '');
|
||||
// Neutralize ANSI escape sequences (e.g. colour codes).
|
||||
const escaped = escapeAnsiCtrlCodes(stripped);
|
||||
// Remove remaining C0/C1 controls that escapeAnsiCtrlCodes doesn't cover.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return escaped.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pad a string to the given display width using spaces.
|
||||
* Uses string-width so CJK characters occupy the correct number of columns.
|
||||
*/
|
||||
function padDisplay(str: string, width: number): string {
|
||||
const currentWidth = stringWidth(str);
|
||||
if (currentWidth >= width) return str;
|
||||
return str + ' '.repeat(width - currentWidth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a string to at most `maxLen` *display columns*.
|
||||
* Appends "..." when truncation occurs and maxLen > 3.
|
||||
*
|
||||
* Unlike String.prototype.slice this iterates by code point and measures
|
||||
* each glyph with string-width, so CJK characters are handled correctly.
|
||||
*/
|
||||
function truncate(str: string, maxLen: number): string {
|
||||
const width = stringWidth(str);
|
||||
if (width <= maxLen) return str;
|
||||
|
||||
const suffix = maxLen > 3 ? '...' : '';
|
||||
const target = maxLen - stringWidth(suffix);
|
||||
|
||||
let result = '';
|
||||
let w = 0;
|
||||
for (const char of str) {
|
||||
w += stringWidth(char);
|
||||
if (w > target) break;
|
||||
result += char;
|
||||
}
|
||||
return result + suffix;
|
||||
}
|
||||
|
||||
function outputHuman(items: SessionListItem[]): void {
|
||||
if (items.length === 0) {
|
||||
writeStdoutLine('No sessions found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const termWidth = process.stdout.columns ?? 80;
|
||||
// 4 = spaces between the 5 columns (SESSION TIME TITLE BRANCH PROMPT)
|
||||
const PROMPT_COL = Math.max(
|
||||
20,
|
||||
termWidth - SESSION_COL - TIME_COL - TITLE_COL - BRANCH_COL - 4,
|
||||
);
|
||||
|
||||
const header =
|
||||
padDisplay('SESSION ID', SESSION_COL) +
|
||||
' ' +
|
||||
padDisplay('STARTED', TIME_COL) +
|
||||
' ' +
|
||||
padDisplay('TITLE', TITLE_COL) +
|
||||
' ' +
|
||||
padDisplay('BRANCH', BRANCH_COL) +
|
||||
' ' +
|
||||
'PROMPT';
|
||||
|
||||
writeStdoutLine(header);
|
||||
|
||||
for (const item of items) {
|
||||
const sessionId = truncate(
|
||||
sanitize(String(item.sessionId ?? '')),
|
||||
SESSION_COL,
|
||||
);
|
||||
const time = truncate(sanitize(formatTime(item.startTime)), TIME_COL);
|
||||
const sanitizedPrompt = sanitize(item.prompt ?? '');
|
||||
const title = truncate(
|
||||
item.customTitle != null ? sanitize(item.customTitle) : sanitizedPrompt,
|
||||
TITLE_COL,
|
||||
);
|
||||
const branch = truncate(
|
||||
item.gitBranch != null ? sanitize(item.gitBranch) : '-',
|
||||
BRANCH_COL,
|
||||
);
|
||||
const prompt = truncate(sanitizedPrompt, PROMPT_COL);
|
||||
|
||||
writeStdoutLine(
|
||||
`${padDisplay(sessionId, SESSION_COL)} ${padDisplay(time, TIME_COL)} ${padDisplay(title, TITLE_COL)} ${padDisplay(branch, BRANCH_COL)} ${prompt}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function toJsonItem(item: SessionListItem): Record<string, unknown> {
|
||||
return {
|
||||
sessionId: item.sessionId,
|
||||
startTime: item.startTime,
|
||||
mtime: item.mtime,
|
||||
prompt: item.prompt,
|
||||
gitBranch: item.gitBranch ?? null,
|
||||
customTitle: item.customTitle ?? null,
|
||||
titleSource: item.titleSource ?? null,
|
||||
filePath: item.filePath,
|
||||
cwd: item.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
function formatError(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
export interface ListArgs {
|
||||
json?: boolean;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export async function handleList(argv: ListArgs): Promise<void> {
|
||||
let svc: SessionService;
|
||||
try {
|
||||
svc = initSessionService();
|
||||
} catch (err) {
|
||||
writeStderrLine(
|
||||
`Error: failed to initialize session service: ${formatError(err)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
let result: ListSessionsResult;
|
||||
try {
|
||||
result = await svc.listSessions({
|
||||
size: argv.limit ?? 20,
|
||||
});
|
||||
} catch (err) {
|
||||
writeStderrLine(`Error: failed to list sessions: ${formatError(err)}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (argv.json) {
|
||||
for (const item of result.items) {
|
||||
writeStdoutLine(JSON.stringify(toJsonItem(item)));
|
||||
}
|
||||
// Emit hasMore hint via stderr so it never contaminates the stdout JSON
|
||||
// stream, keeping pipelines like `qwen sessions list --json | jq …` safe.
|
||||
if (result.items.length > 0 && result.hasMore) {
|
||||
writeStderrLine(
|
||||
`Note: ${result.items.length} sessions shown, more available. Use --limit to show more.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
outputHuman(result.items);
|
||||
if (result.items.length > 0 && result.hasMore) {
|
||||
writeStdoutLine(
|
||||
`Showing ${result.items.length} sessions. Use --limit to show more.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const listCommand: CommandModule<unknown, ListArgs> = {
|
||||
command: 'list',
|
||||
describe: 'List sessions',
|
||||
builder: (yargs: Argv) =>
|
||||
yargs
|
||||
.option('json', {
|
||||
type: 'boolean',
|
||||
describe: 'Output as JSON Lines',
|
||||
default: false,
|
||||
})
|
||||
.option('limit', {
|
||||
type: 'number',
|
||||
describe: 'Maximum number of sessions to show',
|
||||
default: 20,
|
||||
coerce: (v) => (Number.isInteger(v) && v > 0 ? v : 20),
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleList(argv);
|
||||
},
|
||||
};
|
||||
|
|
@ -61,6 +61,7 @@ import { channelCommand } from '../commands/channel.js';
|
|||
import { authCommand } from '../commands/auth.js';
|
||||
import { reviewCommand } from '../commands/review.js';
|
||||
import { serveCommand } from '../commands/serve.js';
|
||||
import { sessionsCommand } from '../commands/sessions.js';
|
||||
|
||||
// UUID v4 regex pattern for validation
|
||||
const SESSION_ID_REGEX =
|
||||
|
|
@ -1035,7 +1036,9 @@ export async function parseArguments(): Promise<CliArgs> {
|
|||
// Register /review skill helpers (presubmit checks, cleanup)
|
||||
.command(reviewCommand)
|
||||
// Register `qwen serve` (Stage 1 daemon)
|
||||
.command(serveCommand);
|
||||
.command(serveCommand)
|
||||
// Register sessions subcommands
|
||||
.command(sessionsCommand);
|
||||
|
||||
yargsInstance
|
||||
.version(await getCliVersion()) // This will enable the --version flag based on package.json
|
||||
|
|
@ -1059,7 +1062,8 @@ export async function parseArguments(): Promise<CliArgs> {
|
|||
result._[0] === 'auth' ||
|
||||
result._[0] === 'hooks' ||
|
||||
result._[0] === 'channel' ||
|
||||
result._[0] === 'review')
|
||||
result._[0] === 'review' ||
|
||||
result._[0] === 'sessions')
|
||||
) {
|
||||
// Note: `serve` is intentionally NOT in this list. Its handler blocks
|
||||
// forever (after the listener is up); SIGINT/SIGTERM in runQwenServe
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue