mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-15 00:21:09 -07:00
feat(ui): build interactive session browser component (#13351)
This commit is contained in:
@@ -52,6 +52,8 @@ function createTestSessions(): SessionInfo[] {
|
||||
fileName: `${SESSION_FILE_PREFIX}2025-01-20T10-30-00-current12.json`,
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Current session',
|
||||
firstUserMessage: 'Current session',
|
||||
isCurrentSession: true,
|
||||
index: 1,
|
||||
@@ -62,6 +64,8 @@ function createTestSessions(): SessionInfo[] {
|
||||
fileName: `${SESSION_FILE_PREFIX}2025-01-18T15-45-00-recent45.json`,
|
||||
startTime: oneWeekAgo.toISOString(),
|
||||
lastUpdated: oneWeekAgo.toISOString(),
|
||||
messageCount: 10,
|
||||
displayName: 'Recent session',
|
||||
firstUserMessage: 'Recent session',
|
||||
isCurrentSession: false,
|
||||
index: 2,
|
||||
@@ -72,6 +76,8 @@ function createTestSessions(): SessionInfo[] {
|
||||
fileName: `${SESSION_FILE_PREFIX}2025-01-10T09-15-00-old789ab.json`,
|
||||
startTime: twoWeeksAgo.toISOString(),
|
||||
lastUpdated: twoWeeksAgo.toISOString(),
|
||||
messageCount: 3,
|
||||
displayName: 'Old session',
|
||||
firstUserMessage: 'Old session',
|
||||
isCurrentSession: false,
|
||||
index: 3,
|
||||
@@ -82,6 +88,8 @@ function createTestSessions(): SessionInfo[] {
|
||||
fileName: `${SESSION_FILE_PREFIX}2024-12-25T12-00-00-ancient1.json`,
|
||||
startTime: oneMonthAgo.toISOString(),
|
||||
lastUpdated: oneMonthAgo.toISOString(),
|
||||
messageCount: 15,
|
||||
displayName: 'Ancient session',
|
||||
firstUserMessage: 'Ancient session',
|
||||
isCurrentSession: false,
|
||||
index: 4,
|
||||
@@ -435,6 +443,8 @@ describe('Session Cleanup', () => {
|
||||
fileName: `${SESSION_FILE_PREFIX}current.json`,
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
messageCount: 1,
|
||||
displayName: 'Current',
|
||||
firstUserMessage: 'Current',
|
||||
isCurrentSession: true,
|
||||
index: 1,
|
||||
@@ -445,6 +455,8 @@ describe('Session Cleanup', () => {
|
||||
fileName: `${SESSION_FILE_PREFIX}5d.json`,
|
||||
startTime: fiveDaysAgo.toISOString(),
|
||||
lastUpdated: fiveDaysAgo.toISOString(),
|
||||
messageCount: 1,
|
||||
displayName: '5 days old',
|
||||
firstUserMessage: '5 days',
|
||||
isCurrentSession: false,
|
||||
index: 2,
|
||||
@@ -455,6 +467,8 @@ describe('Session Cleanup', () => {
|
||||
fileName: `${SESSION_FILE_PREFIX}8d.json`,
|
||||
startTime: eightDaysAgo.toISOString(),
|
||||
lastUpdated: eightDaysAgo.toISOString(),
|
||||
messageCount: 1,
|
||||
displayName: '8 days old',
|
||||
firstUserMessage: '8 days',
|
||||
isCurrentSession: false,
|
||||
index: 3,
|
||||
@@ -465,6 +479,8 @@ describe('Session Cleanup', () => {
|
||||
fileName: `${SESSION_FILE_PREFIX}15d.json`,
|
||||
startTime: fifteenDaysAgo.toISOString(),
|
||||
lastUpdated: fifteenDaysAgo.toISOString(),
|
||||
messageCount: 1,
|
||||
displayName: '15 days old',
|
||||
firstUserMessage: '15 days',
|
||||
isCurrentSession: false,
|
||||
index: 4,
|
||||
@@ -549,6 +565,8 @@ describe('Session Cleanup', () => {
|
||||
fileName: `${SESSION_FILE_PREFIX}current.json`,
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
messageCount: 1,
|
||||
displayName: 'Current',
|
||||
firstUserMessage: 'Current',
|
||||
isCurrentSession: true,
|
||||
index: 1,
|
||||
@@ -559,6 +577,8 @@ describe('Session Cleanup', () => {
|
||||
fileName: `${SESSION_FILE_PREFIX}1d.json`,
|
||||
startTime: oneDayAgo.toISOString(),
|
||||
lastUpdated: oneDayAgo.toISOString(),
|
||||
messageCount: 1,
|
||||
displayName: '1 day old',
|
||||
firstUserMessage: '1 day',
|
||||
isCurrentSession: false,
|
||||
index: 2,
|
||||
@@ -569,6 +589,8 @@ describe('Session Cleanup', () => {
|
||||
fileName: `${SESSION_FILE_PREFIX}7d.json`,
|
||||
startTime: sevenDaysAgo.toISOString(),
|
||||
lastUpdated: sevenDaysAgo.toISOString(),
|
||||
messageCount: 1,
|
||||
displayName: '7 days old',
|
||||
firstUserMessage: '7 days',
|
||||
isCurrentSession: false,
|
||||
index: 3,
|
||||
@@ -579,6 +601,8 @@ describe('Session Cleanup', () => {
|
||||
fileName: `${SESSION_FILE_PREFIX}13d.json`,
|
||||
startTime: thirteenDaysAgo.toISOString(),
|
||||
lastUpdated: thirteenDaysAgo.toISOString(),
|
||||
messageCount: 1,
|
||||
displayName: '13 days old',
|
||||
firstUserMessage: '13 days',
|
||||
isCurrentSession: false,
|
||||
index: 4,
|
||||
@@ -637,6 +661,8 @@ describe('Session Cleanup', () => {
|
||||
fileName: `${SESSION_FILE_PREFIX}current.json`,
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
messageCount: 1,
|
||||
displayName: 'Current (newest)',
|
||||
firstUserMessage: 'Current',
|
||||
isCurrentSession: true,
|
||||
index: 1,
|
||||
@@ -652,6 +678,8 @@ describe('Session Cleanup', () => {
|
||||
fileName: `${SESSION_FILE_PREFIX}${i}d.json`,
|
||||
startTime: daysAgo.toISOString(),
|
||||
lastUpdated: daysAgo.toISOString(),
|
||||
messageCount: 1,
|
||||
displayName: `${i} days old`,
|
||||
firstUserMessage: `${i} days`,
|
||||
isCurrentSession: false,
|
||||
index: i + 1,
|
||||
@@ -759,6 +787,8 @@ describe('Session Cleanup', () => {
|
||||
fileName: `${SESSION_FILE_PREFIX}current.json`,
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
messageCount: 1,
|
||||
displayName: 'Current',
|
||||
firstUserMessage: 'Current',
|
||||
isCurrentSession: true,
|
||||
index: 1,
|
||||
@@ -769,6 +799,8 @@ describe('Session Cleanup', () => {
|
||||
fileName: `${SESSION_FILE_PREFIX}3d.json`,
|
||||
startTime: threeDaysAgo.toISOString(),
|
||||
lastUpdated: threeDaysAgo.toISOString(),
|
||||
messageCount: 1,
|
||||
displayName: '3 days old',
|
||||
firstUserMessage: '3 days',
|
||||
isCurrentSession: false,
|
||||
index: 2,
|
||||
@@ -779,6 +811,8 @@ describe('Session Cleanup', () => {
|
||||
fileName: `${SESSION_FILE_PREFIX}5d.json`,
|
||||
startTime: fiveDaysAgo.toISOString(),
|
||||
lastUpdated: fiveDaysAgo.toISOString(),
|
||||
messageCount: 1,
|
||||
displayName: '5 days old',
|
||||
firstUserMessage: '5 days',
|
||||
isCurrentSession: false,
|
||||
index: 3,
|
||||
@@ -789,6 +823,8 @@ describe('Session Cleanup', () => {
|
||||
fileName: `${SESSION_FILE_PREFIX}7d.json`,
|
||||
startTime: sevenDaysAgo.toISOString(),
|
||||
lastUpdated: sevenDaysAgo.toISOString(),
|
||||
messageCount: 1,
|
||||
displayName: '7 days old',
|
||||
firstUserMessage: '7 days',
|
||||
isCurrentSession: false,
|
||||
index: 4,
|
||||
@@ -799,6 +835,8 @@ describe('Session Cleanup', () => {
|
||||
fileName: `${SESSION_FILE_PREFIX}12d.json`,
|
||||
startTime: twelveDaysAgo.toISOString(),
|
||||
lastUpdated: twelveDaysAgo.toISOString(),
|
||||
messageCount: 1,
|
||||
displayName: '12 days old',
|
||||
firstUserMessage: '12 days',
|
||||
isCurrentSession: false,
|
||||
index: 5,
|
||||
|
||||
@@ -234,6 +234,70 @@ describe('SessionSelector', () => {
|
||||
expect(result.sessionData.messages[0].content).toBe('Latest session');
|
||||
});
|
||||
|
||||
it('should deduplicate sessions by ID', async () => {
|
||||
const sessionId = randomUUID();
|
||||
|
||||
// Create test session files
|
||||
const chatsDir = path.join(tmpDir, 'chats');
|
||||
await fs.mkdir(chatsDir, { recursive: true });
|
||||
|
||||
const sessionOriginal = {
|
||||
sessionId,
|
||||
projectHash: 'test-hash',
|
||||
startTime: '2024-01-01T10:00:00.000Z',
|
||||
lastUpdated: '2024-01-01T10:30:00.000Z',
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
content: 'Original',
|
||||
id: 'msg1',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const sessionDuplicate = {
|
||||
sessionId,
|
||||
projectHash: 'test-hash',
|
||||
startTime: '2024-01-01T10:00:00.000Z',
|
||||
lastUpdated: '2024-01-01T11:00:00.000Z', // Newer
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
content: 'Newer Duplicate',
|
||||
id: 'msg1',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// File 1
|
||||
await fs.writeFile(
|
||||
path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId.slice(0, 8)}.json`,
|
||||
),
|
||||
JSON.stringify(sessionOriginal, null, 2),
|
||||
);
|
||||
|
||||
// File 2 (Simulate a copy or newer version with same ID)
|
||||
await fs.writeFile(
|
||||
path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2024-01-01T11-00-${sessionId.slice(0, 8)}.json`,
|
||||
),
|
||||
JSON.stringify(sessionDuplicate, null, 2),
|
||||
);
|
||||
|
||||
const sessionSelector = new SessionSelector(config);
|
||||
const sessions = await sessionSelector.listSessions();
|
||||
|
||||
expect(sessions.length).toBe(1);
|
||||
expect(sessions[0].id).toBe(sessionId);
|
||||
// Should keep the one with later lastUpdated
|
||||
expect(sessions[0].lastUpdated).toBe('2024-01-01T11:00:00.000Z');
|
||||
});
|
||||
|
||||
it('should throw error for invalid session identifier', async () => {
|
||||
const sessionId1 = randomUUID();
|
||||
|
||||
@@ -296,7 +360,7 @@ describe('extractFirstUserMessage', () => {
|
||||
expect(extractFirstUserMessage(messages)).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('should truncate long messages', () => {
|
||||
it('should not truncate long messages', () => {
|
||||
const longMessage = 'a'.repeat(150);
|
||||
const messages = [
|
||||
{
|
||||
@@ -308,8 +372,7 @@ describe('extractFirstUserMessage', () => {
|
||||
] as MessageRecord[];
|
||||
|
||||
const result = extractFirstUserMessage(messages);
|
||||
expect(result).toBe('a'.repeat(97) + '...');
|
||||
expect(result.length).toBe(100);
|
||||
expect(result).toBe(longMessage);
|
||||
});
|
||||
|
||||
it('should return "Empty conversation" for no user messages', () => {
|
||||
|
||||
@@ -10,8 +10,8 @@ import type {
|
||||
MessageRecord,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
SESSION_FILE_PREFIX,
|
||||
partListUnionToString,
|
||||
SESSION_FILE_PREFIX,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
@@ -22,6 +22,20 @@ import path from 'node:path';
|
||||
*/
|
||||
export const RESUME_LATEST = 'latest';
|
||||
|
||||
/**
|
||||
* Represents a text match found during search with surrounding context.
|
||||
*/
|
||||
export interface TextMatch {
|
||||
/** Text content before the match (with ellipsis if truncated) */
|
||||
before: string;
|
||||
/** The exact matched text */
|
||||
match: string;
|
||||
/** Text content after the match (with ellipsis if truncated) */
|
||||
after: string;
|
||||
/** Role of the message author where the match was found */
|
||||
role: 'user' | 'assistant';
|
||||
}
|
||||
|
||||
/**
|
||||
* Session information for display and selection purposes.
|
||||
*/
|
||||
@@ -34,14 +48,26 @@ export interface SessionInfo {
|
||||
fileName: string;
|
||||
/** ISO timestamp when session started */
|
||||
startTime: string;
|
||||
/** Total number of messages in the session */
|
||||
messageCount: number;
|
||||
/** ISO timestamp when session was last updated */
|
||||
lastUpdated: string;
|
||||
/** Display name for the session (typically first user message) */
|
||||
displayName: string;
|
||||
/** Cleaned first user message content */
|
||||
firstUserMessage: string;
|
||||
/** Whether this is the currently active session */
|
||||
isCurrentSession: boolean;
|
||||
/** Display index in the list */
|
||||
index: number;
|
||||
/** Full concatenated content (only loaded when needed for search) */
|
||||
fullContent?: string;
|
||||
/** Processed messages with normalized roles (only loaded when needed) */
|
||||
messages?: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
/** Search result snippets when filtering */
|
||||
matchSnippets?: TextMatch[];
|
||||
/** Total number of matches found in this session */
|
||||
matchCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,30 +86,64 @@ export interface SessionFileEntry {
|
||||
export interface SessionSelectionResult {
|
||||
sessionPath: string;
|
||||
sessionData: ConversationRecord;
|
||||
displayInfo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans and sanitizes message content for display by:
|
||||
* - Converting newlines to spaces
|
||||
* - Collapsing multiple whitespace to single spaces
|
||||
* - Removing non-printable characters (keeping only ASCII 32-126)
|
||||
* - Trimming leading/trailing whitespace
|
||||
* @param message - The raw message content to clean
|
||||
* @returns Sanitized message suitable for display
|
||||
*/
|
||||
export const cleanMessage = (message: string): string =>
|
||||
message
|
||||
.replace(/\n+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/[^\x20-\x7E]+/g, '') // Non-printable.
|
||||
.trim();
|
||||
|
||||
/**
|
||||
* Extracts the first meaningful user message from conversation messages.
|
||||
*/
|
||||
export const extractFirstUserMessage = (messages: MessageRecord[]): string => {
|
||||
const userMessage = messages.find((msg) => {
|
||||
const content = partListUnionToString(msg.content);
|
||||
return msg.type === 'user' && content?.trim() && content !== '/resume';
|
||||
});
|
||||
const userMessage = messages
|
||||
// First try filtering out slash commands.
|
||||
.filter((msg) => {
|
||||
const content = partListUnionToString(msg.content);
|
||||
return (
|
||||
!content.startsWith('/') &&
|
||||
!content.startsWith('?') &&
|
||||
content.trim().length > 0
|
||||
);
|
||||
})
|
||||
.find((msg) => msg.type === 'user');
|
||||
|
||||
let content: string;
|
||||
|
||||
if (!userMessage) {
|
||||
return 'Empty conversation';
|
||||
// Fallback to first user message even if it's a slash command
|
||||
const firstMsg = messages.find((msg) => msg.type === 'user');
|
||||
if (!firstMsg) return 'Empty conversation';
|
||||
content = cleanMessage(partListUnionToString(firstMsg.content));
|
||||
} else {
|
||||
content = cleanMessage(partListUnionToString(userMessage.content));
|
||||
}
|
||||
|
||||
// Truncate long messages for display
|
||||
const content = partListUnionToString(userMessage.content).trim();
|
||||
return content.length > 100 ? content.slice(0, 97) + '...' : content;
|
||||
return content;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a timestamp as relative time (e.g., "2 hours ago", "3 days ago").
|
||||
* Formats a timestamp as relative time.
|
||||
* @param timestamp - The timestamp to format
|
||||
* @param style - 'long' (e.g. "2 hours ago") or 'short' (e.g. "2h")
|
||||
*/
|
||||
export const formatRelativeTime = (timestamp: string): string => {
|
||||
export const formatRelativeTime = (
|
||||
timestamp: string,
|
||||
style: 'long' | 'short' = 'long',
|
||||
): string => {
|
||||
const now = new Date();
|
||||
const time = new Date(timestamp);
|
||||
const diffMs = now.getTime() - time.getTime();
|
||||
@@ -92,17 +152,34 @@ export const formatRelativeTime = (timestamp: string): string => {
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffDays > 0) {
|
||||
return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
||||
} else if (diffHours > 0) {
|
||||
return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
|
||||
} else if (diffMinutes > 0) {
|
||||
return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`;
|
||||
if (style === 'short') {
|
||||
if (diffSeconds < 1) return 'now';
|
||||
if (diffSeconds < 60) return `${diffSeconds}s`;
|
||||
if (diffMinutes < 60) return `${diffMinutes}m`;
|
||||
if (diffHours < 24) return `${diffHours}h`;
|
||||
if (diffDays < 30) return `${diffDays}d`;
|
||||
const diffMonths = Math.floor(diffDays / 30);
|
||||
return diffMonths < 12
|
||||
? `${diffMonths}mo`
|
||||
: `${Math.floor(diffMonths / 12)}y`;
|
||||
} else {
|
||||
return 'Just now';
|
||||
if (diffDays > 0) {
|
||||
return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
||||
} else if (diffHours > 0) {
|
||||
return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
|
||||
} else if (diffMinutes > 0) {
|
||||
return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`;
|
||||
} else {
|
||||
return 'Just now';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export interface GetSessionOptions {
|
||||
/** Whether to load full message content (needed for search) */
|
||||
includeFullContent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all session files (including corrupted ones) from the chats directory.
|
||||
* @returns Array of session file entries, with sessionInfo null for corrupted files
|
||||
@@ -110,6 +187,7 @@ export const formatRelativeTime = (timestamp: string): string => {
|
||||
export const getAllSessionFiles = async (
|
||||
chatsDir: string,
|
||||
currentSessionId?: string,
|
||||
options: GetSessionOptions = {},
|
||||
): Promise<SessionFileEntry[]> => {
|
||||
try {
|
||||
const files = await fs.readdir(chatsDir);
|
||||
@@ -142,15 +220,37 @@ export const getAllSessionFiles = async (
|
||||
? file.includes(currentSessionId.slice(0, 8))
|
||||
: false;
|
||||
|
||||
let fullContent: string | undefined;
|
||||
let messages:
|
||||
| Array<{ role: 'user' | 'assistant'; content: string }>
|
||||
| undefined;
|
||||
|
||||
if (options.includeFullContent) {
|
||||
fullContent = content.messages
|
||||
.map((msg) => partListUnionToString(msg.content))
|
||||
.join(' ');
|
||||
messages = content.messages.map((msg) => ({
|
||||
role:
|
||||
msg.type === 'user'
|
||||
? ('user' as const)
|
||||
: ('assistant' as const),
|
||||
content: partListUnionToString(msg.content),
|
||||
}));
|
||||
}
|
||||
|
||||
const sessionInfo: SessionInfo = {
|
||||
id: content.sessionId,
|
||||
file: file.replace('.json', ''),
|
||||
fileName: file,
|
||||
startTime: content.startTime,
|
||||
lastUpdated: content.lastUpdated,
|
||||
messageCount: content.messages.length,
|
||||
displayName: firstUserMessage,
|
||||
firstUserMessage,
|
||||
isCurrentSession,
|
||||
index: 0, // Will be set after sorting valid sessions
|
||||
fullContent,
|
||||
messages,
|
||||
};
|
||||
|
||||
return { fileName: file, sessionInfo };
|
||||
@@ -179,8 +279,13 @@ export const getAllSessionFiles = async (
|
||||
export const getSessionFiles = async (
|
||||
chatsDir: string,
|
||||
currentSessionId?: string,
|
||||
options: GetSessionOptions = {},
|
||||
): Promise<SessionInfo[]> => {
|
||||
const allFiles = await getAllSessionFiles(chatsDir, currentSessionId);
|
||||
const allFiles = await getAllSessionFiles(
|
||||
chatsDir,
|
||||
currentSessionId,
|
||||
options,
|
||||
);
|
||||
|
||||
// Filter out corrupted files and extract SessionInfo
|
||||
const validSessions = allFiles
|
||||
@@ -190,17 +295,31 @@ export const getSessionFiles = async (
|
||||
)
|
||||
.map((entry) => entry.sessionInfo);
|
||||
|
||||
// Deduplicate sessions by ID
|
||||
const uniqueSessionsMap = new Map<string, SessionInfo>();
|
||||
for (const session of validSessions) {
|
||||
// If duplicate exists, keep the one with the later lastUpdated timestamp
|
||||
if (
|
||||
!uniqueSessionsMap.has(session.id) ||
|
||||
new Date(session.lastUpdated).getTime() >
|
||||
new Date(uniqueSessionsMap.get(session.id)!.lastUpdated).getTime()
|
||||
) {
|
||||
uniqueSessionsMap.set(session.id, session);
|
||||
}
|
||||
}
|
||||
const uniqueSessions = Array.from(uniqueSessionsMap.values());
|
||||
|
||||
// Sort by startTime (oldest first) for stable session numbering
|
||||
validSessions.sort(
|
||||
uniqueSessions.sort(
|
||||
(a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
|
||||
);
|
||||
|
||||
// Set the correct 1-based indexes after sorting
|
||||
validSessions.forEach((session, index) => {
|
||||
uniqueSessions.forEach((session, index) => {
|
||||
session.index = index + 1;
|
||||
});
|
||||
|
||||
return validSessions;
|
||||
return uniqueSessions;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -318,9 +437,12 @@ export class SessionSelector {
|
||||
await fs.readFile(sessionPath, 'utf8'),
|
||||
);
|
||||
|
||||
const displayInfo = `Session ${sessionInfo.index}: ${sessionInfo.firstUserMessage} (${sessionInfo.messageCount} messages, ${formatRelativeTime(sessionInfo.lastUpdated)})`;
|
||||
|
||||
return {
|
||||
sessionPath,
|
||||
sessionData,
|
||||
displayInfo,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
|
||||
@@ -85,6 +85,8 @@ describe('listSessions', () => {
|
||||
fileName: 'session-2025-01-18T12-00-00-session-1.json',
|
||||
startTime: twoDaysAgo.toISOString(),
|
||||
lastUpdated: twoDaysAgo.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'First user message',
|
||||
firstUserMessage: 'First user message',
|
||||
isCurrentSession: false,
|
||||
index: 1,
|
||||
@@ -95,6 +97,8 @@ describe('listSessions', () => {
|
||||
fileName: 'session-2025-01-20T11-00-00-session-2.json',
|
||||
startTime: oneHourAgo.toISOString(),
|
||||
lastUpdated: oneHourAgo.toISOString(),
|
||||
messageCount: 10,
|
||||
displayName: 'Second user message',
|
||||
firstUserMessage: 'Second user message',
|
||||
isCurrentSession: false,
|
||||
index: 2,
|
||||
@@ -105,6 +109,8 @@ describe('listSessions', () => {
|
||||
fileName: 'session-2025-01-20T12-00-00-current-s.json',
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
messageCount: 3,
|
||||
displayName: 'Current session',
|
||||
firstUserMessage: 'Current session',
|
||||
isCurrentSession: true,
|
||||
index: 3,
|
||||
@@ -163,6 +169,8 @@ describe('listSessions', () => {
|
||||
fileName: 'session-2.json',
|
||||
startTime: session2Time.toISOString(), // Middle
|
||||
lastUpdated: session2Time.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Middle session',
|
||||
firstUserMessage: 'Middle session',
|
||||
isCurrentSession: false,
|
||||
index: 2,
|
||||
@@ -173,6 +181,8 @@ describe('listSessions', () => {
|
||||
fileName: 'session-1.json',
|
||||
startTime: session1Time.toISOString(), // Oldest
|
||||
lastUpdated: session1Time.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Oldest session',
|
||||
firstUserMessage: 'Oldest session',
|
||||
isCurrentSession: false,
|
||||
index: 1,
|
||||
@@ -183,6 +193,8 @@ describe('listSessions', () => {
|
||||
fileName: 'session-3.json',
|
||||
startTime: session3Time.toISOString(), // Newest
|
||||
lastUpdated: session3Time.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Newest session',
|
||||
firstUserMessage: 'Newest session',
|
||||
isCurrentSession: false,
|
||||
index: 3,
|
||||
@@ -219,6 +231,8 @@ describe('listSessions', () => {
|
||||
fileName: 'session-file.json',
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Test message',
|
||||
firstUserMessage: 'Test message',
|
||||
isCurrentSession: false,
|
||||
index: 1,
|
||||
@@ -252,6 +266,8 @@ describe('listSessions', () => {
|
||||
fileName: 'session-file.json',
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Only session',
|
||||
firstUserMessage: 'Only session',
|
||||
isCurrentSession: true,
|
||||
index: 1,
|
||||
@@ -348,6 +364,8 @@ describe('deleteSession', () => {
|
||||
fileName: 'session-file-123.json',
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Test session',
|
||||
firstUserMessage: 'Test session',
|
||||
isCurrentSession: false,
|
||||
index: 1,
|
||||
@@ -381,6 +399,8 @@ describe('deleteSession', () => {
|
||||
fileName: 'session-file-1.json',
|
||||
startTime: oneHourAgo.toISOString(),
|
||||
lastUpdated: oneHourAgo.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'First session',
|
||||
firstUserMessage: 'First session',
|
||||
isCurrentSession: false,
|
||||
index: 1,
|
||||
@@ -391,6 +411,8 @@ describe('deleteSession', () => {
|
||||
fileName: 'session-file-2.json',
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
messageCount: 10,
|
||||
displayName: 'Second session',
|
||||
firstUserMessage: 'Second session',
|
||||
isCurrentSession: false,
|
||||
index: 2,
|
||||
@@ -421,6 +443,8 @@ describe('deleteSession', () => {
|
||||
fileName: 'session-file-1.json',
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Test session',
|
||||
firstUserMessage: 'Test session',
|
||||
isCurrentSession: false,
|
||||
index: 1,
|
||||
@@ -449,6 +473,8 @@ describe('deleteSession', () => {
|
||||
fileName: 'session-file-1.json',
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Test session',
|
||||
firstUserMessage: 'Test session',
|
||||
isCurrentSession: false,
|
||||
index: 1,
|
||||
@@ -477,6 +503,8 @@ describe('deleteSession', () => {
|
||||
fileName: 'session-file-1.json',
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Test session',
|
||||
firstUserMessage: 'Test session',
|
||||
isCurrentSession: false,
|
||||
index: 1,
|
||||
@@ -505,6 +533,8 @@ describe('deleteSession', () => {
|
||||
fileName: 'current-session-file.json',
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Current session',
|
||||
firstUserMessage: 'Current session',
|
||||
isCurrentSession: true,
|
||||
index: 1,
|
||||
@@ -533,6 +563,8 @@ describe('deleteSession', () => {
|
||||
fileName: 'current-session-file.json',
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Current session',
|
||||
firstUserMessage: 'Current session',
|
||||
isCurrentSession: true,
|
||||
index: 1,
|
||||
@@ -561,6 +593,8 @@ describe('deleteSession', () => {
|
||||
fileName: 'session-file-1.json',
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Test session',
|
||||
firstUserMessage: 'Test session',
|
||||
isCurrentSession: false,
|
||||
index: 1,
|
||||
@@ -592,6 +626,8 @@ describe('deleteSession', () => {
|
||||
fileName: 'session-file-1.json',
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Test session',
|
||||
firstUserMessage: 'Test session',
|
||||
isCurrentSession: false,
|
||||
index: 1,
|
||||
@@ -626,6 +662,8 @@ describe('deleteSession', () => {
|
||||
fileName: 'session-file-3.json',
|
||||
startTime: session3Time.toISOString(), // Newest
|
||||
lastUpdated: session3Time.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Newest session',
|
||||
firstUserMessage: 'Newest session',
|
||||
isCurrentSession: false,
|
||||
index: 3,
|
||||
@@ -636,6 +674,8 @@ describe('deleteSession', () => {
|
||||
fileName: 'session-file-1.json',
|
||||
startTime: session1Time.toISOString(), // Oldest
|
||||
lastUpdated: session1Time.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Oldest session',
|
||||
firstUserMessage: 'Oldest session',
|
||||
isCurrentSession: false,
|
||||
index: 1,
|
||||
@@ -646,6 +686,8 @@ describe('deleteSession', () => {
|
||||
fileName: 'session-file-2.json',
|
||||
startTime: session2Time.toISOString(), // Middle
|
||||
lastUpdated: session2Time.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Middle session',
|
||||
firstUserMessage: 'Middle session',
|
||||
isCurrentSession: false,
|
||||
index: 2,
|
||||
|
||||
@@ -30,8 +30,12 @@ export async function listSessions(config: Config): Promise<void> {
|
||||
.forEach((session, index) => {
|
||||
const current = session.isCurrentSession ? ', current' : '';
|
||||
const time = formatRelativeTime(session.lastUpdated);
|
||||
const title =
|
||||
session.firstUserMessage.length > 100
|
||||
? session.firstUserMessage.slice(0, 97) + '...'
|
||||
: session.firstUserMessage;
|
||||
console.log(
|
||||
` ${index + 1}. ${session.firstUserMessage} (${time}${current}) [${session.id}]`,
|
||||
` ${index + 1}. ${title} (${time}${current}) [${session.id}]`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user