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:
Zqc 2026-06-17 17:06:05 +08:00 committed by GitHub
parent a335f9cdfd
commit 14e6ae8c2b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 905 additions and 2 deletions

View 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.',
);
});
});

View 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: () => {},
};

View 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());
});
});

View 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());
}

View 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);
});
});

View 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);
},
};

View file

@ -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