From 621b8bd507b71d2ad8b46bb35d41f251f59418c9 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 23:44:03 +0900 Subject: [PATCH] takt: github-issue-180-ai (#219) --- .../cli-routing-issue-resolve.test.ts | 51 +++- src/__tests__/interactive.test.ts | 36 +++ src/__tests__/session-reader.test.ts | 261 ++++++++++++++++++ src/__tests__/sessionSelector.test.ts | 156 +++++++++++ src/app/cli/routing.ts | 14 +- src/features/interactive/index.ts | 1 + src/features/interactive/interactive.ts | 4 +- src/features/interactive/sessionSelector.ts | 103 +++++++ src/infra/claude/index.ts | 1 + src/infra/claude/session-reader.ts | 123 +++++++++ src/shared/i18n/labels_en.yaml | 6 + src/shared/i18n/labels_ja.yaml | 6 + 12 files changed, 758 insertions(+), 4 deletions(-) create mode 100644 src/__tests__/session-reader.test.ts create mode 100644 src/__tests__/sessionSelector.test.ts create mode 100644 src/features/interactive/sessionSelector.ts create mode 100644 src/infra/claude/session-reader.ts diff --git a/src/__tests__/cli-routing-issue-resolve.test.ts b/src/__tests__/cli-routing-issue-resolve.test.ts index ca3dda0..5e7fb93 100644 --- a/src/__tests__/cli-routing-issue-resolve.test.ts +++ b/src/__tests__/cli-routing-issue-resolve.test.ts @@ -47,6 +47,7 @@ vi.mock('../features/pipeline/index.js', () => ({ vi.mock('../features/interactive/index.js', () => ({ interactiveMode: vi.fn(), selectInteractiveMode: vi.fn(() => 'assistant'), + selectRecentSession: vi.fn(() => null), passthroughMode: vi.fn(), quietMode: vi.fn(), personaMode: vi.fn(), @@ -85,7 +86,8 @@ vi.mock('../app/cli/helpers.js', () => ({ import { checkGhCli, fetchIssue, formatIssueAsTask, parseIssueNumbers } from '../infra/github/issue.js'; import { selectAndExecuteTask, determinePiece, createIssueAndSaveTask } from '../features/tasks/index.js'; -import { interactiveMode } from '../features/interactive/index.js'; +import { interactiveMode, selectRecentSession } from '../features/interactive/index.js'; +import { loadGlobalConfig } from '../infra/config/index.js'; import { isDirectTask } from '../app/cli/helpers.js'; import { executeDefaultAction } from '../app/cli/routing.js'; import type { GitHubIssue } from '../infra/github/types.js'; @@ -98,6 +100,8 @@ const mockSelectAndExecuteTask = vi.mocked(selectAndExecuteTask); const mockDeterminePiece = vi.mocked(determinePiece); const mockCreateIssueAndSaveTask = vi.mocked(createIssueAndSaveTask); const mockInteractiveMode = vi.mocked(interactiveMode); +const mockSelectRecentSession = vi.mocked(selectRecentSession); +const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); const mockIsDirectTask = vi.mocked(isDirectTask); function createMockIssue(number: number): GitHubIssue { @@ -144,6 +148,7 @@ describe('Issue resolution in routing', () => { '/test/cwd', '## GitHub Issue #131: Issue #131', expect.anything(), + undefined, ); // Then: selectAndExecuteTask should receive issues in options @@ -196,6 +201,7 @@ describe('Issue resolution in routing', () => { '/test/cwd', '## GitHub Issue #131: Issue #131', expect.anything(), + undefined, ); // Then: selectAndExecuteTask should receive issues @@ -220,6 +226,7 @@ describe('Issue resolution in routing', () => { '/test/cwd', 'refactor the code', expect.anything(), + undefined, ); // Then: no issue fetching should occur @@ -239,6 +246,7 @@ describe('Issue resolution in routing', () => { '/test/cwd', undefined, expect.anything(), + undefined, ); // Then: no issue fetching should occur @@ -291,4 +299,45 @@ describe('Issue resolution in routing', () => { expect(mockSelectAndExecuteTask).not.toHaveBeenCalled(); }); }); + + describe('session selection with provider=claude', () => { + it('should pass selected session ID to interactiveMode when provider is claude', async () => { + // Given + mockLoadGlobalConfig.mockReturnValue({ interactivePreviewMovements: 3, provider: 'claude' }); + mockSelectRecentSession.mockResolvedValue('session-xyz'); + + // When + await executeDefaultAction(); + + // Then: selectRecentSession should be called + expect(mockSelectRecentSession).toHaveBeenCalledWith('/test/cwd', 'en'); + + // Then: interactiveMode should receive the session ID as 4th argument + expect(mockInteractiveMode).toHaveBeenCalledWith( + '/test/cwd', + undefined, + expect.anything(), + 'session-xyz', + ); + }); + + it('should not call selectRecentSession when provider is not claude', async () => { + // Given + mockLoadGlobalConfig.mockReturnValue({ interactivePreviewMovements: 3, provider: 'openai' }); + + // When + await executeDefaultAction(); + + // Then: selectRecentSession should NOT be called + expect(mockSelectRecentSession).not.toHaveBeenCalled(); + + // Then: interactiveMode should be called with undefined session ID + expect(mockInteractiveMode).toHaveBeenCalledWith( + '/test/cwd', + undefined, + expect.anything(), + undefined, + ); + }); + }); }); diff --git a/src/__tests__/interactive.test.ts b/src/__tests__/interactive.test.ts index d531042..1419be9 100644 --- a/src/__tests__/interactive.test.ts +++ b/src/__tests__/interactive.test.ts @@ -369,6 +369,42 @@ describe('interactiveMode', () => { expect(result.task).toBe('Fix login page with clarified scope.'); }); + it('should pass sessionId to provider when sessionId parameter is given', async () => { + // Given + setupRawStdin(toRawInputs(['hello', '/cancel'])); + setupMockProvider(['AI response']); + + // When + await interactiveMode('/project', undefined, undefined, 'test-session-id'); + + // Then: provider call should include the overridden sessionId + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + expect(mockProvider._call).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + sessionId: 'test-session-id', + }), + ); + }); + + it('should use saved sessionId from initializeSession when no sessionId parameter is given', async () => { + // Given + setupRawStdin(toRawInputs(['hello', '/cancel'])); + setupMockProvider(['AI response']); + + // When: no sessionId parameter + await interactiveMode('/project'); + + // Then: provider call should include sessionId from initializeSession (undefined in mock) + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + expect(mockProvider._call).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + sessionId: undefined, + }), + ); + }); + describe('/play command', () => { it('should return action=execute with task on /play command', async () => { // Given diff --git a/src/__tests__/session-reader.test.ts b/src/__tests__/session-reader.test.ts new file mode 100644 index 0000000..b5baed0 --- /dev/null +++ b/src/__tests__/session-reader.test.ts @@ -0,0 +1,261 @@ +/** + * Tests for Claude Code session reader + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +// Mock getClaudeProjectSessionsDir to point to our temp directory +let mockSessionsDir: string; + +vi.mock('../infra/config/project/sessionStore.js', () => ({ + getClaudeProjectSessionsDir: vi.fn(() => mockSessionsDir), +})); + +import { loadSessionIndex, extractLastAssistantResponse } from '../infra/claude/session-reader.js'; + +describe('loadSessionIndex', () => { + beforeEach(() => { + mockSessionsDir = mkdtempSync(join(tmpdir(), 'session-reader-test-')); + }); + + it('returns empty array when sessions-index.json does not exist', () => { + const result = loadSessionIndex('/nonexistent'); + expect(result).toEqual([]); + }); + + it('reads and parses sessions-index.json correctly', () => { + const indexData = { + version: 1, + entries: [ + { + sessionId: 'aaa', + firstPrompt: 'First session', + modified: '2026-01-28T10:00:00.000Z', + messageCount: 5, + gitBranch: 'main', + isSidechain: false, + fullPath: '/path/to/aaa.jsonl', + }, + { + sessionId: 'bbb', + firstPrompt: 'Second session', + modified: '2026-01-29T10:00:00.000Z', + messageCount: 10, + gitBranch: '', + isSidechain: false, + fullPath: '/path/to/bbb.jsonl', + }, + ], + }; + + writeFileSync(join(mockSessionsDir, 'sessions-index.json'), JSON.stringify(indexData)); + + const result = loadSessionIndex('/any'); + expect(result).toHaveLength(2); + // Sorted by modified descending: bbb (Jan 29) first, then aaa (Jan 28) + expect(result[0]!.sessionId).toBe('bbb'); + expect(result[1]!.sessionId).toBe('aaa'); + }); + + it('filters out sidechain sessions', () => { + const indexData = { + version: 1, + entries: [ + { + sessionId: 'main-session', + firstPrompt: 'User conversation', + modified: '2026-01-28T10:00:00.000Z', + messageCount: 5, + gitBranch: '', + isSidechain: false, + fullPath: '/path/to/main.jsonl', + }, + { + sessionId: 'sidechain-session', + firstPrompt: 'Sub-agent work', + modified: '2026-01-29T10:00:00.000Z', + messageCount: 20, + gitBranch: '', + isSidechain: true, + fullPath: '/path/to/sidechain.jsonl', + }, + ], + }; + + writeFileSync(join(mockSessionsDir, 'sessions-index.json'), JSON.stringify(indexData)); + + const result = loadSessionIndex('/any'); + expect(result).toHaveLength(1); + expect(result[0]!.sessionId).toBe('main-session'); + }); + + it('returns empty array when entries is missing', () => { + writeFileSync(join(mockSessionsDir, 'sessions-index.json'), JSON.stringify({ version: 1 })); + + const result = loadSessionIndex('/any'); + expect(result).toEqual([]); + }); + + it('returns empty array when sessions-index.json contains corrupted JSON', () => { + writeFileSync(join(mockSessionsDir, 'sessions-index.json'), '{corrupted json content'); + + const result = loadSessionIndex('/any'); + expect(result).toEqual([]); + }); +}); + +describe('extractLastAssistantResponse', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'session-reader-extract-')); + }); + + it('returns null when file does not exist', () => { + const result = extractLastAssistantResponse('/nonexistent/file.jsonl', 200); + expect(result).toBeNull(); + }); + + it('extracts text from last assistant message', () => { + const lines = [ + JSON.stringify({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: 'Hello' }] }, + }), + JSON.stringify({ + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'First response' }] }, + }), + JSON.stringify({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: 'Follow up' }] }, + }), + JSON.stringify({ + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'Last response here' }] }, + }), + ]; + + const filePath = join(tempDir, 'session.jsonl'); + writeFileSync(filePath, lines.join('\n')); + + const result = extractLastAssistantResponse(filePath, 200); + expect(result).toBe('Last response here'); + }); + + it('skips assistant messages with only tool_use content', () => { + const lines = [ + JSON.stringify({ + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'Text response' }] }, + }), + JSON.stringify({ + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'tool_use', id: 'tool1', name: 'Read', input: {} }] }, + }), + ]; + + const filePath = join(tempDir, 'session.jsonl'); + writeFileSync(filePath, lines.join('\n')); + + const result = extractLastAssistantResponse(filePath, 200); + expect(result).toBe('Text response'); + }); + + it('returns null when no assistant messages have text', () => { + const lines = [ + JSON.stringify({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: 'Hello' }] }, + }), + JSON.stringify({ + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'tool_use', id: 'tool1', name: 'Read', input: {} }] }, + }), + ]; + + const filePath = join(tempDir, 'session.jsonl'); + writeFileSync(filePath, lines.join('\n')); + + const result = extractLastAssistantResponse(filePath, 200); + expect(result).toBeNull(); + }); + + it('truncates long responses', () => { + const longText = 'A'.repeat(300); + const lines = [ + JSON.stringify({ + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: longText }] }, + }), + ]; + + const filePath = join(tempDir, 'session.jsonl'); + writeFileSync(filePath, lines.join('\n')); + + const result = extractLastAssistantResponse(filePath, 200); + expect(result).toHaveLength(201); // 200 chars + '…' + expect(result!.endsWith('…')).toBe(true); + }); + + it('concatenates multiple text blocks in a single message', () => { + const lines = [ + JSON.stringify({ + type: 'assistant', + message: { + role: 'assistant', + content: [ + { type: 'text', text: 'Part one' }, + { type: 'tool_use', id: 'tool1', name: 'Read', input: {} }, + { type: 'text', text: 'Part two' }, + ], + }, + }), + ]; + + const filePath = join(tempDir, 'session.jsonl'); + writeFileSync(filePath, lines.join('\n')); + + const result = extractLastAssistantResponse(filePath, 200); + expect(result).toBe('Part one\nPart two'); + }); + + it('handles malformed JSON lines gracefully', () => { + const lines = [ + 'not valid json', + JSON.stringify({ + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'Valid response' }] }, + }), + '{also broken', + ]; + + const filePath = join(tempDir, 'session.jsonl'); + writeFileSync(filePath, lines.join('\n')); + + const result = extractLastAssistantResponse(filePath, 200); + expect(result).toBe('Valid response'); + }); + + it('handles progress and other non-assistant record types', () => { + const lines = [ + JSON.stringify({ + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'Response' }] }, + }), + JSON.stringify({ + type: 'progress', + data: { type: 'hook_progress' }, + }), + ]; + + const filePath = join(tempDir, 'session.jsonl'); + writeFileSync(filePath, lines.join('\n')); + + const result = extractLastAssistantResponse(filePath, 200); + expect(result).toBe('Response'); + }); +}); diff --git a/src/__tests__/sessionSelector.test.ts b/src/__tests__/sessionSelector.test.ts new file mode 100644 index 0000000..9bb7941 --- /dev/null +++ b/src/__tests__/sessionSelector.test.ts @@ -0,0 +1,156 @@ +/** + * Tests for session selector + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { SessionIndexEntry } from '../infra/claude/session-reader.js'; + +const mockLoadSessionIndex = vi.fn<(dir: string) => SessionIndexEntry[]>(); +const mockExtractLastAssistantResponse = vi.fn<(path: string, maxLen: number) => string | null>(); + +vi.mock('../infra/claude/session-reader.js', () => ({ + loadSessionIndex: (...args: [string]) => mockLoadSessionIndex(...args), + extractLastAssistantResponse: (...args: [string, number]) => mockExtractLastAssistantResponse(...args), +})); + +const mockSelectOption = vi.fn<(prompt: string, options: unknown[]) => Promise>(); + +vi.mock('../shared/prompt/index.js', () => ({ + selectOption: (...args: [string, unknown[]]) => mockSelectOption(...args), +})); + +vi.mock('../shared/i18n/index.js', () => ({ + getLabel: (key: string, _lang: string, params?: Record) => { + if (key === 'interactive.sessionSelector.newSession') return 'New session'; + if (key === 'interactive.sessionSelector.newSessionDescription') return 'Start a new conversation'; + if (key === 'interactive.sessionSelector.messages') return `${params?.count} messages`; + if (key === 'interactive.sessionSelector.lastResponse') return `Last: ${params?.response}`; + if (key === 'interactive.sessionSelector.prompt') return 'Select a session'; + return key; + }, +})); + +import { selectRecentSession } from '../features/interactive/sessionSelector.js'; + +describe('selectRecentSession', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return null when no sessions exist', async () => { + mockLoadSessionIndex.mockReturnValue([]); + + const result = await selectRecentSession('/project', 'en'); + + expect(result).toBeNull(); + expect(mockSelectOption).not.toHaveBeenCalled(); + }); + + it('should return null when user selects __new__', async () => { + mockLoadSessionIndex.mockReturnValue([ + createSession('session-1', 'Hello world', '2026-01-28T10:00:00.000Z'), + ]); + mockExtractLastAssistantResponse.mockReturnValue(null); + mockSelectOption.mockResolvedValue('__new__'); + + const result = await selectRecentSession('/project', 'en'); + + expect(result).toBeNull(); + }); + + it('should return null when user cancels selection', async () => { + mockLoadSessionIndex.mockReturnValue([ + createSession('session-1', 'Hello world', '2026-01-28T10:00:00.000Z'), + ]); + mockExtractLastAssistantResponse.mockReturnValue(null); + mockSelectOption.mockResolvedValue(null); + + const result = await selectRecentSession('/project', 'en'); + + expect(result).toBeNull(); + }); + + it('should return sessionId when user selects a session', async () => { + mockLoadSessionIndex.mockReturnValue([ + createSession('session-abc', 'Fix the bug', '2026-01-28T10:00:00.000Z'), + ]); + mockExtractLastAssistantResponse.mockReturnValue(null); + mockSelectOption.mockResolvedValue('session-abc'); + + const result = await selectRecentSession('/project', 'en'); + + expect(result).toBe('session-abc'); + }); + + it('should pass correct options to selectOption with new session first', async () => { + mockLoadSessionIndex.mockReturnValue([ + createSession('s1', 'First prompt', '2026-01-28T10:00:00.000Z', 5), + ]); + mockExtractLastAssistantResponse.mockReturnValue('Some response'); + mockSelectOption.mockResolvedValue('s1'); + + await selectRecentSession('/project', 'en'); + + expect(mockSelectOption).toHaveBeenCalledWith( + 'Select a session', + expect.arrayContaining([ + expect.objectContaining({ value: '__new__', label: 'New session' }), + expect.objectContaining({ value: 's1' }), + ]), + ); + + const options = mockSelectOption.mock.calls[0]![1] as Array<{ value: string }>; + expect(options[0]!.value).toBe('__new__'); + expect(options[1]!.value).toBe('s1'); + }); + + it('should limit display to MAX_DISPLAY_SESSIONS (10)', async () => { + const sessions = Array.from({ length: 15 }, (_, i) => + createSession(`s${i}`, `Prompt ${i}`, `2026-01-${String(i + 10).padStart(2, '0')}T10:00:00.000Z`), + ); + mockLoadSessionIndex.mockReturnValue(sessions); + mockExtractLastAssistantResponse.mockReturnValue(null); + mockSelectOption.mockResolvedValue(null); + + await selectRecentSession('/project', 'en'); + + const options = mockSelectOption.mock.calls[0]![1] as Array<{ value: string }>; + // 1 new session + 10 display sessions = 11 total + expect(options).toHaveLength(11); + }); + + it('should include last response details when available', async () => { + mockLoadSessionIndex.mockReturnValue([ + createSession('s1', 'Hello', '2026-01-28T10:00:00.000Z', 3, '/path/to/s1.jsonl'), + ]); + mockExtractLastAssistantResponse.mockReturnValue('AI response text'); + mockSelectOption.mockResolvedValue('s1'); + + await selectRecentSession('/project', 'en'); + + expect(mockExtractLastAssistantResponse).toHaveBeenCalledWith('/path/to/s1.jsonl', 200); + + const options = mockSelectOption.mock.calls[0]![1] as Array<{ value: string; details?: string[] }>; + const sessionOption = options[1]!; + expect(sessionOption.details).toBeDefined(); + expect(sessionOption.details![0]).toContain('AI response text'); + }); +}); + +function createSession( + sessionId: string, + firstPrompt: string, + modified: string, + messageCount = 5, + fullPath = `/path/to/${sessionId}.jsonl`, +): SessionIndexEntry { + return { + sessionId, + firstPrompt, + modified, + messageCount, + gitBranch: 'main', + isSidechain: false, + fullPath, + }; +} diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index 4339441..b485477 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -14,6 +14,7 @@ import { executePipeline } from '../../features/pipeline/index.js'; import { interactiveMode, selectInteractiveMode, + selectRecentSession, passthroughMode, quietMode, personaMode, @@ -162,9 +163,18 @@ export async function executeDefaultAction(task?: string): Promise { let result: InteractiveModeResult; switch (selectedMode) { - case 'assistant': - result = await interactiveMode(resolvedCwd, initialInput, pieceContext); + case 'assistant': { + let selectedSessionId: string | undefined; + const provider = globalConfig.provider; + if (provider === 'claude') { + const sessionId = await selectRecentSession(resolvedCwd, lang); + if (sessionId) { + selectedSessionId = sessionId; + } + } + result = await interactiveMode(resolvedCwd, initialInput, pieceContext, selectedSessionId); break; + } case 'passthrough': result = await passthroughMode(lang, initialInput); diff --git a/src/features/interactive/index.ts b/src/features/interactive/index.ts index 66b5e9d..56bda3f 100644 --- a/src/features/interactive/index.ts +++ b/src/features/interactive/index.ts @@ -15,6 +15,7 @@ export { } from './interactive.js'; export { selectInteractiveMode } from './modeSelection.js'; +export { selectRecentSession } from './sessionSelector.js'; export { passthroughMode } from './passthroughMode.js'; export { quietMode } from './quietMode.js'; export { personaMode } from './personaMode.js'; diff --git a/src/features/interactive/interactive.ts b/src/features/interactive/interactive.ts index 0e67737..36b9104 100644 --- a/src/features/interactive/interactive.ts +++ b/src/features/interactive/interactive.ts @@ -221,8 +221,10 @@ export async function interactiveMode( cwd: string, initialInput?: string, pieceContext?: PieceContext, + sessionId?: string, ): Promise { - const ctx = initializeSession(cwd, 'interactive'); + const baseCtx = initializeSession(cwd, 'interactive'); + const ctx = sessionId ? { ...baseCtx, sessionId } : baseCtx; displayAndClearSessionState(cwd, ctx.lang); diff --git a/src/features/interactive/sessionSelector.ts b/src/features/interactive/sessionSelector.ts new file mode 100644 index 0000000..c7fd611 --- /dev/null +++ b/src/features/interactive/sessionSelector.ts @@ -0,0 +1,103 @@ +/** + * Session selector for interactive mode + * + * Presents recent Claude Code sessions for the user to choose from, + * allowing them to resume a previous conversation as the assistant. + */ + +import { loadSessionIndex, extractLastAssistantResponse } from '../../infra/claude/session-reader.js'; +import { selectOption, type SelectOptionItem } from '../../shared/prompt/index.js'; +import { getLabel } from '../../shared/i18n/index.js'; + +/** Maximum number of sessions to display */ +const MAX_DISPLAY_SESSIONS = 10; + +/** Maximum length for last response preview */ +const MAX_RESPONSE_PREVIEW_LENGTH = 200; + +/** + * Format a modified date for display. + */ +function formatModifiedDate(modified: string, lang: 'en' | 'ja'): string { + const date = new Date(modified); + return date.toLocaleString(lang === 'ja' ? 'ja-JP' : 'en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +/** + * Truncate a single-line string for use as a label. + */ +function truncateForLabel(text: string, maxLength: number): string { + const singleLine = text.replace(/\n/g, ' ').trim(); + if (singleLine.length <= maxLength) { + return singleLine; + } + return singleLine.slice(0, maxLength) + '…'; +} + +/** + * Prompt user to select from recent Claude Code sessions. + * + * @param cwd - Current working directory (project directory) + * @param lang - Display language + * @returns Selected session ID, or null for new session / no sessions + */ +export async function selectRecentSession( + cwd: string, + lang: 'en' | 'ja', +): Promise { + const sessions = loadSessionIndex(cwd); + + if (sessions.length === 0) { + return null; + } + + const displaySessions = sessions.slice(0, MAX_DISPLAY_SESSIONS); + + const options: SelectOptionItem[] = [ + { + label: getLabel('interactive.sessionSelector.newSession', lang), + value: '__new__', + description: getLabel('interactive.sessionSelector.newSessionDescription', lang), + }, + ]; + + for (const session of displaySessions) { + const label = truncateForLabel(session.firstPrompt, 60); + const dateStr = formatModifiedDate(session.modified, lang); + const messagesStr = getLabel('interactive.sessionSelector.messages', lang, { + count: String(session.messageCount), + }); + const description = `${dateStr} | ${messagesStr}`; + + const details: string[] = []; + const lastResponse = extractLastAssistantResponse(session.fullPath, MAX_RESPONSE_PREVIEW_LENGTH); + if (lastResponse) { + const previewLine = lastResponse.replace(/\n/g, ' ').trim(); + const preview = getLabel('interactive.sessionSelector.lastResponse', lang, { + response: previewLine, + }); + details.push(preview); + } + + options.push({ + label, + value: session.sessionId, + description, + details: details.length > 0 ? details : undefined, + }); + } + + const prompt = getLabel('interactive.sessionSelector.prompt', lang); + const selected = await selectOption(prompt, options); + + if (selected === null || selected === '__new__') { + return null; + } + + return selected; +} diff --git a/src/infra/claude/index.ts b/src/infra/claude/index.ts index 8e8060d..5e2cb5c 100644 --- a/src/infra/claude/index.ts +++ b/src/infra/claude/index.ts @@ -70,3 +70,4 @@ export { detectRuleIndex, isRegexSafe, } from './client.js'; + diff --git a/src/infra/claude/session-reader.ts b/src/infra/claude/session-reader.ts new file mode 100644 index 0000000..82e6b1e --- /dev/null +++ b/src/infra/claude/session-reader.ts @@ -0,0 +1,123 @@ +/** + * Claude Code session reader + * + * Reads Claude Code's sessions-index.json and individual .jsonl session files + * to extract session metadata and last assistant responses. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { getClaudeProjectSessionsDir } from '../config/project/sessionStore.js'; + +/** Entry in Claude Code's sessions-index.json */ +export interface SessionIndexEntry { + sessionId: string; + firstPrompt: string; + modified: string; + messageCount: number; + gitBranch: string; + isSidechain: boolean; + fullPath: string; +} + +/** Shape of sessions-index.json */ +interface SessionsIndex { + version: number; + entries: SessionIndexEntry[]; +} + +/** + * Load the session index for a project directory. + * + * Reads ~/.claude/projects/{encoded-path}/sessions-index.json, + * filters out sidechain sessions, and sorts by modified descending. + */ +export function loadSessionIndex(projectDir: string): SessionIndexEntry[] { + const sessionsDir = getClaudeProjectSessionsDir(projectDir); + const indexPath = join(sessionsDir, 'sessions-index.json'); + + if (!existsSync(indexPath)) { + return []; + } + + const content = readFileSync(indexPath, 'utf-8'); + + let index: SessionsIndex; + try { + index = JSON.parse(content) as SessionsIndex; + } catch { + return []; + } + + if (!index.entries || !Array.isArray(index.entries)) { + return []; + } + + return index.entries + .filter((entry) => !entry.isSidechain) + .sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime()); +} + +/** Content block with text type from Claude API */ +interface TextContentBlock { + type: 'text'; + text: string; +} + +/** Message structure in JSONL records */ +interface AssistantMessage { + content: Array; +} + +/** JSONL record for assistant messages */ +interface SessionRecord { + type: string; + message?: AssistantMessage; +} + +/** + * Extract the last assistant text response from a session JSONL file. + * + * Reads the file and scans from the end to find the last `type: "assistant"` + * record with a text content block. Returns the truncated text. + */ +export function extractLastAssistantResponse(sessionFilePath: string, maxLength: number): string | null { + if (!existsSync(sessionFilePath)) { + return null; + } + + const content = readFileSync(sessionFilePath, 'utf-8'); + const lines = content.split('\n').filter((line) => line.trim()); + + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + if (!line) continue; + + let record: SessionRecord; + try { + record = JSON.parse(line) as SessionRecord; + } catch { + continue; + } + + if (record.type !== 'assistant' || !record.message?.content) { + continue; + } + + const textBlocks = record.message.content.filter( + (block): block is TextContentBlock => block.type === 'text', + ); + + if (textBlocks.length === 0) { + continue; + } + + const fullText = textBlocks.map((b) => b.text).join('\n'); + if (fullText.length <= maxLength) { + return fullText; + } + return fullText.slice(0, maxLength) + '…'; + } + + return null; +} diff --git a/src/shared/i18n/labels_en.yaml b/src/shared/i18n/labels_en.yaml index c2624a1..f85f8bc 100644 --- a/src/shared/i18n/labels_en.yaml +++ b/src/shared/i18n/labels_en.yaml @@ -35,6 +35,12 @@ interactive: quietDescription: "Generate instructions without asking questions" passthrough: "Passthrough" passthroughDescription: "Pass your input directly as task text" + sessionSelector: + prompt: "Resume from a recent session?" + newSession: "New session" + newSessionDescription: "Start a fresh conversation" + lastResponse: "Last: {response}" + messages: "{count} messages" previousTask: success: "✅ Previous task completed successfully" error: "❌ Previous task failed: {error}" diff --git a/src/shared/i18n/labels_ja.yaml b/src/shared/i18n/labels_ja.yaml index e2ddbf6..1931d87 100644 --- a/src/shared/i18n/labels_ja.yaml +++ b/src/shared/i18n/labels_ja.yaml @@ -35,6 +35,12 @@ interactive: quietDescription: "質問なしでベストエフォートの指示書を生成" passthrough: "パススルー" passthroughDescription: "入力をそのままタスクとして渡す" + sessionSelector: + prompt: "直近のセッションを引き継ぎますか?" + newSession: "新しいセッション" + newSessionDescription: "新しい会話を始める" + lastResponse: "最後: {response}" + messages: "{count}メッセージ" previousTask: success: "✅ 前回のタスクは正常に完了しました" error: "❌ 前回のタスクはエラーで終了しました: {error}"