From a66eb24009f7cd32b61a7e7fb7eab0771efb6663 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:07:10 +0900 Subject: [PATCH] module --- src/codex/CodexStreamHandler.ts | 320 ++++++++++++++++ src/codex/client.ts | 345 ++---------------- src/commands/management/listTasks.ts | 371 ++----------------- src/commands/management/taskActions.ts | 331 +++++++++++++++++ src/config/global/globalConfig.ts | 217 ++++++----- src/config/global/index.ts | 1 + src/config/loaders/workflowLoader.ts | 459 +---------------------- src/config/loaders/workflowParser.ts | 197 ++++++++++ src/config/loaders/workflowResolver.ts | 189 ++++++++++ src/prompt/confirm.ts | 101 ++++++ src/prompt/index.ts | 415 +-------------------- src/prompt/select.ts | 280 ++++++++++++++ src/utils/LogManager.ts | 155 ++++++++ src/utils/Spinner.ts | 43 +++ src/utils/StreamDisplay.ts | 283 +++++++++++++++ src/utils/session.ts | 103 +----- src/utils/types.ts | 93 +++++ src/utils/ui.ts | 483 ++----------------------- 18 files changed, 2239 insertions(+), 2147 deletions(-) create mode 100644 src/codex/CodexStreamHandler.ts create mode 100644 src/commands/management/taskActions.ts create mode 100644 src/config/loaders/workflowParser.ts create mode 100644 src/config/loaders/workflowResolver.ts create mode 100644 src/prompt/confirm.ts create mode 100644 src/prompt/select.ts create mode 100644 src/utils/LogManager.ts create mode 100644 src/utils/Spinner.ts create mode 100644 src/utils/StreamDisplay.ts create mode 100644 src/utils/types.ts diff --git a/src/codex/CodexStreamHandler.ts b/src/codex/CodexStreamHandler.ts new file mode 100644 index 0000000..c6ab1d5 --- /dev/null +++ b/src/codex/CodexStreamHandler.ts @@ -0,0 +1,320 @@ +/** + * Codex stream event handling. + * + * Converts Codex SDK events into the unified StreamCallback format + * used throughout the takt codebase. + */ + +import type { StreamCallback } from '../claude/types.js'; + +export type CodexEvent = { + type: string; + [key: string]: unknown; +}; + +export type CodexItem = { + id?: string; + type: string; + [key: string]: unknown; +}; + +/** Tracking state for stream offsets during a single Codex thread run */ +export interface StreamTrackingState { + startedItems: Set; + outputOffsets: Map; + textOffsets: Map; + thinkingOffsets: Map; +} + +export function createStreamTrackingState(): StreamTrackingState { + return { + startedItems: new Set(), + outputOffsets: new Map(), + textOffsets: new Map(), + thinkingOffsets: new Map(), + }; +} + +// ---- Stream emission helpers ---- + +export function extractThreadId(value: unknown): string | undefined { + if (!value || typeof value !== 'object') return undefined; + const record = value as Record; + const id = record.id ?? record.thread_id ?? record.threadId; + return typeof id === 'string' ? id : undefined; +} + +export function emitInit( + onStream: StreamCallback | undefined, + model: string | undefined, + sessionId: string | undefined, +): void { + if (!onStream) return; + onStream({ + type: 'init', + data: { + model: model || 'codex', + sessionId: sessionId || 'unknown', + }, + }); +} + +export function emitText(onStream: StreamCallback | undefined, text: string): void { + if (!onStream || !text) return; + onStream({ type: 'text', data: { text } }); +} + +export function emitThinking(onStream: StreamCallback | undefined, thinking: string): void { + if (!onStream || !thinking) return; + onStream({ type: 'thinking', data: { thinking } }); +} + +export function emitToolUse( + onStream: StreamCallback | undefined, + tool: string, + input: Record, + id: string, +): void { + if (!onStream) return; + onStream({ type: 'tool_use', data: { tool, input, id } }); +} + +export function emitToolResult( + onStream: StreamCallback | undefined, + content: string, + isError: boolean, +): void { + if (!onStream) return; + onStream({ type: 'tool_result', data: { content, isError } }); +} + +export function emitToolOutput( + onStream: StreamCallback | undefined, + tool: string, + output: string, +): void { + if (!onStream || !output) return; + onStream({ type: 'tool_output', data: { tool, output } }); +} + +export function emitResult( + onStream: StreamCallback | undefined, + success: boolean, + result: string, + sessionId: string | undefined, +): void { + if (!onStream) return; + onStream({ + type: 'result', + data: { + result, + sessionId: sessionId || 'unknown', + success, + error: success ? undefined : result || undefined, + }, + }); +} + +export function formatFileChangeSummary(changes: Array<{ path?: string; kind?: string }>): string { + if (!changes.length) return ''; + return changes + .map((change) => { + const kind = change.kind ? `${change.kind}: ` : ''; + return `${kind}${change.path ?? ''}`.trim(); + }) + .filter(Boolean) + .join('\n'); +} + +export function emitCodexItemStart( + item: CodexItem, + onStream: StreamCallback | undefined, + startedItems: Set, +): void { + if (!onStream) return; + const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`; + if (startedItems.has(id)) return; + + switch (item.type) { + case 'command_execution': { + const command = typeof item.command === 'string' ? item.command : ''; + emitToolUse(onStream, 'Bash', { command }, id); + startedItems.add(id); + break; + } + case 'mcp_tool_call': { + const tool = typeof item.tool === 'string' ? item.tool : 'Tool'; + const args = (item.arguments ?? {}) as Record; + emitToolUse(onStream, tool, args, id); + startedItems.add(id); + break; + } + case 'web_search': { + const query = typeof item.query === 'string' ? item.query : ''; + emitToolUse(onStream, 'WebSearch', { query }, id); + startedItems.add(id); + break; + } + case 'file_change': { + const changes = Array.isArray(item.changes) ? item.changes : []; + const summary = formatFileChangeSummary(changes as Array<{ path?: string; kind?: string }>); + emitToolUse(onStream, 'Edit', { file_path: summary || 'patch' }, id); + startedItems.add(id); + break; + } + default: + break; + } +} + +export function emitCodexItemCompleted( + item: CodexItem, + onStream: StreamCallback | undefined, + state: StreamTrackingState, +): void { + if (!onStream) return; + const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`; + + switch (item.type) { + case 'reasoning': { + const text = typeof item.text === 'string' ? item.text : ''; + if (text) { + const prev = state.thinkingOffsets.get(id) ?? 0; + if (text.length > prev) { + emitThinking(onStream, text.slice(prev) + '\n'); + state.thinkingOffsets.set(id, text.length); + } + } + break; + } + case 'agent_message': { + const text = typeof item.text === 'string' ? item.text : ''; + if (text) { + const prev = state.textOffsets.get(id) ?? 0; + if (text.length > prev) { + emitText(onStream, text.slice(prev)); + state.textOffsets.set(id, text.length); + } + } + break; + } + case 'command_execution': { + if (!state.startedItems.has(id)) { + emitCodexItemStart(item, onStream, state.startedItems); + } + const output = typeof item.aggregated_output === 'string' ? item.aggregated_output : ''; + if (output) { + const prev = state.outputOffsets.get(id) ?? 0; + if (output.length > prev) { + emitToolOutput(onStream, 'Bash', output.slice(prev)); + state.outputOffsets.set(id, output.length); + } + } + const exitCode = typeof item.exit_code === 'number' ? item.exit_code : undefined; + const status = typeof item.status === 'string' ? item.status : ''; + const isError = status === 'failed' || (exitCode !== undefined && exitCode !== 0); + const content = output || (exitCode !== undefined ? `Exit code: ${exitCode}` : ''); + emitToolResult(onStream, content, isError); + break; + } + case 'mcp_tool_call': { + if (!state.startedItems.has(id)) { + emitCodexItemStart(item, onStream, state.startedItems); + } + const status = typeof item.status === 'string' ? item.status : ''; + const isError = status === 'failed' || !!item.error; + const errorMessage = + item.error && typeof item.error === 'object' && 'message' in item.error + ? String((item.error as { message?: unknown }).message ?? '') + : ''; + let content = errorMessage; + if (!content && item.result && typeof item.result === 'object') { + try { + content = JSON.stringify(item.result); + } catch { + content = ''; + } + } + emitToolResult(onStream, content, isError); + break; + } + case 'web_search': { + if (!state.startedItems.has(id)) { + emitCodexItemStart(item, onStream, state.startedItems); + } + emitToolResult(onStream, 'Search completed', false); + break; + } + case 'file_change': { + if (!state.startedItems.has(id)) { + emitCodexItemStart(item, onStream, state.startedItems); + } + const status = typeof item.status === 'string' ? item.status : ''; + const isError = status === 'failed'; + const changes = Array.isArray(item.changes) ? item.changes : []; + const summary = formatFileChangeSummary(changes as Array<{ path?: string; kind?: string }>); + emitToolResult(onStream, summary || 'Applied patch', isError); + break; + } + default: + break; + } +} + +export function emitCodexItemUpdate( + item: CodexItem, + onStream: StreamCallback | undefined, + state: StreamTrackingState, +): void { + if (!onStream) return; + const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`; + + switch (item.type) { + case 'command_execution': { + if (!state.startedItems.has(id)) { + emitCodexItemStart(item, onStream, state.startedItems); + } + const output = typeof item.aggregated_output === 'string' ? item.aggregated_output : ''; + if (output) { + const prev = state.outputOffsets.get(id) ?? 0; + if (output.length > prev) { + emitToolOutput(onStream, 'Bash', output.slice(prev)); + state.outputOffsets.set(id, output.length); + } + } + break; + } + case 'agent_message': { + const text = typeof item.text === 'string' ? item.text : ''; + if (text) { + const prev = state.textOffsets.get(id) ?? 0; + if (text.length > prev) { + emitText(onStream, text.slice(prev)); + state.textOffsets.set(id, text.length); + } + } + break; + } + case 'reasoning': { + const text = typeof item.text === 'string' ? item.text : ''; + if (text) { + const prev = state.thinkingOffsets.get(id) ?? 0; + if (text.length > prev) { + emitThinking(onStream, text.slice(prev)); + state.thinkingOffsets.set(id, text.length); + } + } + break; + } + case 'file_change': + case 'mcp_tool_call': + case 'web_search': { + if (!state.startedItems.has(id)) { + emitCodexItemStart(item, onStream, state.startedItems); + } + break; + } + default: + break; + } +} diff --git a/src/codex/client.ts b/src/codex/client.ts index 05828f7..76859bd 100644 --- a/src/codex/client.ts +++ b/src/codex/client.ts @@ -5,28 +5,27 @@ */ import { Codex } from '@openai/codex-sdk'; -import type { AgentResponse, Status } from '../models/types.js'; -import type { StreamCallback } from '../claude/types.js'; +import type { AgentResponse } from '../models/types.js'; import { createLogger } from '../utils/debug.js'; import { getErrorMessage } from '../utils/error.js'; import type { CodexCallOptions } from './types.js'; +import { + type CodexEvent, + type CodexItem, + createStreamTrackingState, + extractThreadId, + emitInit, + emitResult, + emitCodexItemStart, + emitCodexItemCompleted, + emitCodexItemUpdate, +} from './CodexStreamHandler.js'; // Re-export for backward compatibility export type { CodexCallOptions } from './types.js'; const log = createLogger('codex-sdk'); -type CodexEvent = { - type: string; - [key: string]: unknown; -}; - -type CodexItem = { - id?: string; - type: string; - [key: string]: unknown; -}; - /** * Client for Codex SDK agent interactions. * @@ -34,298 +33,6 @@ type CodexItem = { * and response processing. */ export class CodexClient { - // ---- Stream emission helpers (private) ---- - - private static extractThreadId(value: unknown): string | undefined { - if (!value || typeof value !== 'object') return undefined; - const record = value as Record; - const id = record.id ?? record.thread_id ?? record.threadId; - return typeof id === 'string' ? id : undefined; - } - - private static emitInit( - onStream: StreamCallback | undefined, - model: string | undefined, - sessionId: string | undefined, - ): void { - if (!onStream) return; - onStream({ - type: 'init', - data: { - model: model || 'codex', - sessionId: sessionId || 'unknown', - }, - }); - } - - private static emitText(onStream: StreamCallback | undefined, text: string): void { - if (!onStream || !text) return; - onStream({ type: 'text', data: { text } }); - } - - private static emitThinking(onStream: StreamCallback | undefined, thinking: string): void { - if (!onStream || !thinking) return; - onStream({ type: 'thinking', data: { thinking } }); - } - - private static emitToolUse( - onStream: StreamCallback | undefined, - tool: string, - input: Record, - id: string, - ): void { - if (!onStream) return; - onStream({ type: 'tool_use', data: { tool, input, id } }); - } - - private static emitToolResult( - onStream: StreamCallback | undefined, - content: string, - isError: boolean, - ): void { - if (!onStream) return; - onStream({ type: 'tool_result', data: { content, isError } }); - } - - private static emitToolOutput( - onStream: StreamCallback | undefined, - tool: string, - output: string, - ): void { - if (!onStream || !output) return; - onStream({ type: 'tool_output', data: { tool, output } }); - } - - private static emitResult( - onStream: StreamCallback | undefined, - success: boolean, - result: string, - sessionId: string | undefined, - ): void { - if (!onStream) return; - onStream({ - type: 'result', - data: { - result, - sessionId: sessionId || 'unknown', - success, - error: success ? undefined : result || undefined, - }, - }); - } - - private static formatFileChangeSummary(changes: Array<{ path?: string; kind?: string }>): string { - if (!changes.length) return ''; - return changes - .map((change) => { - const kind = change.kind ? `${change.kind}: ` : ''; - return `${kind}${change.path ?? ''}`.trim(); - }) - .filter(Boolean) - .join('\n'); - } - - private static emitCodexItemStart( - item: CodexItem, - onStream: StreamCallback | undefined, - startedItems: Set, - ): void { - if (!onStream) return; - const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`; - if (startedItems.has(id)) return; - - switch (item.type) { - case 'command_execution': { - const command = typeof item.command === 'string' ? item.command : ''; - CodexClient.emitToolUse(onStream, 'Bash', { command }, id); - startedItems.add(id); - break; - } - case 'mcp_tool_call': { - const tool = typeof item.tool === 'string' ? item.tool : 'Tool'; - const args = (item.arguments ?? {}) as Record; - CodexClient.emitToolUse(onStream, tool, args, id); - startedItems.add(id); - break; - } - case 'web_search': { - const query = typeof item.query === 'string' ? item.query : ''; - CodexClient.emitToolUse(onStream, 'WebSearch', { query }, id); - startedItems.add(id); - break; - } - case 'file_change': { - const changes = Array.isArray(item.changes) ? item.changes : []; - const summary = CodexClient.formatFileChangeSummary(changes as Array<{ path?: string; kind?: string }>); - CodexClient.emitToolUse(onStream, 'Edit', { file_path: summary || 'patch' }, id); - startedItems.add(id); - break; - } - default: - break; - } - } - - private static emitCodexItemCompleted( - item: CodexItem, - onStream: StreamCallback | undefined, - startedItems: Set, - outputOffsets: Map, - textOffsets: Map, - thinkingOffsets: Map, - ): void { - if (!onStream) return; - const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`; - - switch (item.type) { - case 'reasoning': { - const text = typeof item.text === 'string' ? item.text : ''; - if (text) { - const prev = thinkingOffsets.get(id) ?? 0; - if (text.length > prev) { - CodexClient.emitThinking(onStream, text.slice(prev) + '\n'); - thinkingOffsets.set(id, text.length); - } - } - break; - } - case 'agent_message': { - const text = typeof item.text === 'string' ? item.text : ''; - if (text) { - const prev = textOffsets.get(id) ?? 0; - if (text.length > prev) { - CodexClient.emitText(onStream, text.slice(prev)); - textOffsets.set(id, text.length); - } - } - break; - } - case 'command_execution': { - if (!startedItems.has(id)) { - CodexClient.emitCodexItemStart(item, onStream, startedItems); - } - const output = typeof item.aggregated_output === 'string' ? item.aggregated_output : ''; - if (output) { - const prev = outputOffsets.get(id) ?? 0; - if (output.length > prev) { - CodexClient.emitToolOutput(onStream, 'Bash', output.slice(prev)); - outputOffsets.set(id, output.length); - } - } - const exitCode = typeof item.exit_code === 'number' ? item.exit_code : undefined; - const status = typeof item.status === 'string' ? item.status : ''; - const isError = status === 'failed' || (exitCode !== undefined && exitCode !== 0); - const content = output || (exitCode !== undefined ? `Exit code: ${exitCode}` : ''); - CodexClient.emitToolResult(onStream, content, isError); - break; - } - case 'mcp_tool_call': { - if (!startedItems.has(id)) { - CodexClient.emitCodexItemStart(item, onStream, startedItems); - } - const status = typeof item.status === 'string' ? item.status : ''; - const isError = status === 'failed' || !!item.error; - const errorMessage = - item.error && typeof item.error === 'object' && 'message' in item.error - ? String((item.error as { message?: unknown }).message ?? '') - : ''; - let content = errorMessage; - if (!content && item.result && typeof item.result === 'object') { - try { - content = JSON.stringify(item.result); - } catch { - content = ''; - } - } - CodexClient.emitToolResult(onStream, content, isError); - break; - } - case 'web_search': { - if (!startedItems.has(id)) { - CodexClient.emitCodexItemStart(item, onStream, startedItems); - } - CodexClient.emitToolResult(onStream, 'Search completed', false); - break; - } - case 'file_change': { - if (!startedItems.has(id)) { - CodexClient.emitCodexItemStart(item, onStream, startedItems); - } - const status = typeof item.status === 'string' ? item.status : ''; - const isError = status === 'failed'; - const changes = Array.isArray(item.changes) ? item.changes : []; - const summary = CodexClient.formatFileChangeSummary(changes as Array<{ path?: string; kind?: string }>); - CodexClient.emitToolResult(onStream, summary || 'Applied patch', isError); - break; - } - default: - break; - } - } - - private static emitCodexItemUpdate( - item: CodexItem, - onStream: StreamCallback | undefined, - startedItems: Set, - outputOffsets: Map, - textOffsets: Map, - thinkingOffsets: Map, - ): void { - if (!onStream) return; - const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`; - - switch (item.type) { - case 'command_execution': { - if (!startedItems.has(id)) { - CodexClient.emitCodexItemStart(item, onStream, startedItems); - } - const output = typeof item.aggregated_output === 'string' ? item.aggregated_output : ''; - if (output) { - const prev = outputOffsets.get(id) ?? 0; - if (output.length > prev) { - CodexClient.emitToolOutput(onStream, 'Bash', output.slice(prev)); - outputOffsets.set(id, output.length); - } - } - break; - } - case 'agent_message': { - const text = typeof item.text === 'string' ? item.text : ''; - if (text) { - const prev = textOffsets.get(id) ?? 0; - if (text.length > prev) { - CodexClient.emitText(onStream, text.slice(prev)); - textOffsets.set(id, text.length); - } - } - break; - } - case 'reasoning': { - const text = typeof item.text === 'string' ? item.text : ''; - if (text) { - const prev = thinkingOffsets.get(id) ?? 0; - if (text.length > prev) { - CodexClient.emitThinking(onStream, text.slice(prev)); - thinkingOffsets.set(id, text.length); - } - } - break; - } - case 'file_change': - case 'mcp_tool_call': - case 'web_search': { - if (!startedItems.has(id)) { - CodexClient.emitCodexItemStart(item, onStream, startedItems); - } - break; - } - default: - break; - } - } - - // ---- Public API ---- - /** Call Codex with an agent prompt */ async call( agentType: string, @@ -340,7 +47,7 @@ export class CodexClient { const thread = options.sessionId ? await codex.resumeThread(options.sessionId, threadOptions) : await codex.startThread(threadOptions); - let threadId = CodexClient.extractThreadId(thread) || options.sessionId; + let threadId = extractThreadId(thread) || options.sessionId; const fullPrompt = options.systemPrompt ? `${options.systemPrompt}\n\n${prompt}` @@ -358,15 +65,12 @@ export class CodexClient { const contentOffsets = new Map(); let success = true; let failureMessage = ''; - const startedItems = new Set(); - const outputOffsets = new Map(); - const textOffsets = new Map(); - const thinkingOffsets = new Map(); + const state = createStreamTrackingState(); for await (const event of events as AsyncGenerator) { if (event.type === 'thread.started') { threadId = typeof event.thread_id === 'string' ? event.thread_id : threadId; - CodexClient.emitInit(options.onStream, options.model, threadId); + emitInit(options.onStream, options.model, threadId); continue; } @@ -387,7 +91,7 @@ export class CodexClient { if (event.type === 'item.started') { const item = event.item as CodexItem | undefined; if (item) { - CodexClient.emitCodexItemStart(item, options.onStream, startedItems); + emitCodexItemStart(item, options.onStream, state.startedItems); } continue; } @@ -409,7 +113,7 @@ export class CodexClient { } } } - CodexClient.emitCodexItemUpdate(item, options.onStream, startedItems, outputOffsets, textOffsets, thinkingOffsets); + emitCodexItemUpdate(item, options.onStream, state); } continue; } @@ -436,14 +140,7 @@ export class CodexClient { content += text; } } - CodexClient.emitCodexItemCompleted( - item, - options.onStream, - startedItems, - outputOffsets, - textOffsets, - thinkingOffsets, - ); + emitCodexItemCompleted(item, options.onStream, state); } continue; } @@ -451,7 +148,7 @@ export class CodexClient { if (!success) { const message = failureMessage || 'Codex execution failed'; - CodexClient.emitResult(options.onStream, false, message, threadId); + emitResult(options.onStream, false, message, threadId); return { agent: agentType, status: 'blocked', @@ -462,7 +159,7 @@ export class CodexClient { } const trimmed = content.trim(); - CodexClient.emitResult(options.onStream, true, trimmed, threadId); + emitResult(options.onStream, true, trimmed, threadId); return { agent: agentType, @@ -473,7 +170,7 @@ export class CodexClient { }; } catch (error) { const message = getErrorMessage(error); - CodexClient.emitResult(options.onStream, false, message, threadId); + emitResult(options.onStream, false, message, threadId); return { agent: agentType, diff --git a/src/commands/management/listTasks.ts b/src/commands/management/listTasks.ts index be2c81f..55df2c9 100644 --- a/src/commands/management/listTasks.ts +++ b/src/commands/management/listTasks.ts @@ -1,360 +1,42 @@ /** - * List tasks command + * List tasks command — main entry point. * - * Interactive UI for reviewing branch-based task results: - * try merge, merge & cleanup, or delete actions. - * Clones are ephemeral — only branches persist between sessions. + * Interactive UI for reviewing branch-based task results. + * Individual actions (merge, delete, instruct, diff) are in taskActions.ts. */ -import { execFileSync, spawnSync } from 'node:child_process'; -import chalk from 'chalk'; -import { - createTempCloneForBranch, - removeClone, - removeCloneMeta, - cleanupOrphanedClone, -} from '../../task/clone.js'; import { detectDefaultBranch, listTaktBranches, buildListItems, - type BranchListItem, } from '../../task/branchList.js'; -import { autoCommitAndPush } from '../../task/autoCommit.js'; -import { selectOption, confirm, promptInput } from '../../prompt/index.js'; -import { info, success, error as logError, warn, header, blankLine } from '../../utils/ui.js'; +import { selectOption, confirm } from '../../prompt/index.js'; +import { info } from '../../utils/ui.js'; import { createLogger } from '../../utils/debug.js'; -import { getErrorMessage } from '../../utils/error.js'; -import { executeTask, type TaskExecutionOptions } from '../execution/taskExecution.js'; -import { listWorkflows } from '../../config/loaders/workflowLoader.js'; -import { getCurrentWorkflow } from '../../config/paths.js'; -import { DEFAULT_WORKFLOW_NAME } from '../../constants.js'; +import type { TaskExecutionOptions } from '../execution/taskExecution.js'; +import { + type ListAction, + showFullDiff, + showDiffAndPromptAction, + tryMergeBranch, + mergeBranch, + deleteBranch, + instructBranch, +} from './taskActions.js'; + +// Re-export for backward compatibility (tests import from this module) +export { + type ListAction, + isBranchMerged, + showFullDiff, + tryMergeBranch, + mergeBranch, + deleteBranch, + instructBranch, +} from './taskActions.js'; const log = createLogger('list-tasks'); -/** Actions available for a listed branch */ -export type ListAction = 'diff' | 'instruct' | 'try' | 'merge' | 'delete'; - -/** - * Check if a branch has already been merged into HEAD. - */ -export function isBranchMerged(projectDir: string, branch: string): boolean { - try { - execFileSync('git', ['merge-base', '--is-ancestor', branch, 'HEAD'], { - cwd: projectDir, - encoding: 'utf-8', - stdio: 'pipe', - }); - return true; - } catch { - return false; - } -} - -/** - * Show full diff in an interactive pager (less). - * Falls back to direct output if pager is unavailable. - */ -export function showFullDiff( - cwd: string, - defaultBranch: string, - branch: string, -): void { - try { - const result = spawnSync( - 'git', ['diff', '--color=always', `${defaultBranch}...${branch}`], - { cwd, stdio: ['inherit', 'inherit', 'inherit'], env: { ...process.env, GIT_PAGER: 'less -R' } }, - ); - if (result.status !== 0) { - warn('Could not display diff'); - } - } catch { - warn('Could not display diff'); - } -} - -/** - * Show diff stat for a branch and prompt for an action. - */ -async function showDiffAndPromptAction( - cwd: string, - defaultBranch: string, - item: BranchListItem, -): Promise { - header(item.info.branch); - if (item.originalInstruction) { - console.log(chalk.dim(` ${item.originalInstruction}`)); - } - blankLine(); - - // Show diff stat - try { - const stat = execFileSync( - 'git', ['diff', '--stat', `${defaultBranch}...${item.info.branch}`], - { cwd, encoding: 'utf-8', stdio: 'pipe' }, - ); - console.log(stat); - } catch { - warn('Could not generate diff stat'); - } - - // Prompt action - const action = await selectOption( - `Action for ${item.info.branch}:`, - [ - { label: 'View diff', value: 'diff', description: 'Show full diff in pager' }, - { label: 'Instruct', value: 'instruct', description: 'Give additional instructions via temp clone' }, - { label: 'Try merge', value: 'try', description: 'Squash merge (stage changes without commit)' }, - { label: 'Merge & cleanup', value: 'merge', description: 'Merge and delete branch' }, - { label: 'Delete', value: 'delete', description: 'Discard changes, delete branch' }, - ], - ); - - return action; -} - -/** - * Try-merge (squash): stage changes from branch without committing. - * User can inspect staged changes and commit manually if satisfied. - */ -export function tryMergeBranch(projectDir: string, item: BranchListItem): boolean { - const { branch } = item.info; - - try { - execFileSync('git', ['merge', '--squash', branch], { - cwd: projectDir, - encoding: 'utf-8', - stdio: 'pipe', - }); - - success(`Squash-merged ${branch} (changes staged, not committed)`); - info('Run `git status` to see staged changes, `git commit` to finalize, or `git reset` to undo.'); - log.info('Try-merge (squash) completed', { branch }); - return true; - } catch (err) { - const msg = getErrorMessage(err); - logError(`Squash merge failed: ${msg}`); - logError('You may need to resolve conflicts manually.'); - log.error('Try-merge (squash) failed', { branch, error: msg }); - return false; - } -} - -/** - * Merge & cleanup: if already merged, skip merge and just delete the branch. - * Otherwise merge first, then delete the branch. - * No worktree removal needed — clones are ephemeral. - */ -export function mergeBranch(projectDir: string, item: BranchListItem): boolean { - const { branch } = item.info; - const alreadyMerged = isBranchMerged(projectDir, branch); - - try { - // Merge only if not already merged - if (alreadyMerged) { - info(`${branch} is already merged, skipping merge.`); - log.info('Branch already merged, cleanup only', { branch }); - } else { - execFileSync('git', ['merge', branch], { - cwd: projectDir, - encoding: 'utf-8', - stdio: 'pipe', - }); - } - - // Delete the branch - try { - execFileSync('git', ['branch', '-d', branch], { - cwd: projectDir, - encoding: 'utf-8', - stdio: 'pipe', - }); - } catch { - warn(`Could not delete branch ${branch}. You may delete it manually.`); - } - - // Clean up orphaned clone directory if it still exists - cleanupOrphanedClone(projectDir, branch); - - success(`Merged & cleaned up ${branch}`); - log.info('Branch merged & cleaned up', { branch, alreadyMerged }); - return true; - } catch (err) { - const msg = getErrorMessage(err); - logError(`Merge failed: ${msg}`); - logError('You may need to resolve conflicts manually.'); - log.error('Merge & cleanup failed', { branch, error: msg }); - return false; - } -} - -/** - * Delete a branch (discard changes). - * No worktree removal needed — clones are ephemeral. - */ -export function deleteBranch(projectDir: string, item: BranchListItem): boolean { - const { branch } = item.info; - - try { - // Force-delete the branch - execFileSync('git', ['branch', '-D', branch], { - cwd: projectDir, - encoding: 'utf-8', - stdio: 'pipe', - }); - - // Clean up orphaned clone directory if it still exists - cleanupOrphanedClone(projectDir, branch); - - success(`Deleted ${branch}`); - log.info('Branch deleted', { branch }); - return true; - } catch (err) { - const msg = getErrorMessage(err); - logError(`Delete failed: ${msg}`); - log.error('Delete failed', { branch, error: msg }); - return false; - } -} - -/** - * Get the workflow to use for instruction. - * If multiple workflows available, prompt user to select. - */ -async function selectWorkflowForInstruction(projectDir: string): Promise { - const availableWorkflows = listWorkflows(projectDir); - const currentWorkflow = getCurrentWorkflow(projectDir); - - if (availableWorkflows.length === 0) { - return DEFAULT_WORKFLOW_NAME; - } - - if (availableWorkflows.length === 1 && availableWorkflows[0]) { - return availableWorkflows[0]; - } - - // Multiple workflows: let user select - const options = availableWorkflows.map((name) => ({ - label: name === currentWorkflow ? `${name} (current)` : name, - value: name, - })); - - return await selectOption('Select workflow:', options); -} - -/** - * Get branch context: diff stat and commit log from main branch. - */ -function getBranchContext(projectDir: string, branch: string): string { - const defaultBranch = detectDefaultBranch(projectDir); - const lines: string[] = []; - - // Get diff stat - try { - const diffStat = execFileSync( - 'git', ['diff', '--stat', `${defaultBranch}...${branch}`], - { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' } - ).trim(); - if (diffStat) { - lines.push('## 現在の変更内容(mainからの差分)'); - lines.push('```'); - lines.push(diffStat); - lines.push('```'); - } - } catch { - // Ignore errors - } - - // Get commit log - try { - const commitLog = execFileSync( - 'git', ['log', '--oneline', `${defaultBranch}..${branch}`], - { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' } - ).trim(); - if (commitLog) { - lines.push(''); - lines.push('## コミット履歴'); - lines.push('```'); - lines.push(commitLog); - lines.push('```'); - } - } catch { - // Ignore errors - } - - return lines.length > 0 ? lines.join('\n') + '\n\n' : ''; -} - -/** - * Instruct branch: create a temp clone, give additional instructions, - * auto-commit+push, then remove clone. - */ -export async function instructBranch( - projectDir: string, - item: BranchListItem, - options?: TaskExecutionOptions, -): Promise { - const { branch } = item.info; - - // 1. Prompt for instruction - const instruction = await promptInput('Enter instruction'); - if (!instruction) { - info('Cancelled'); - return false; - } - - // 2. Select workflow - const selectedWorkflow = await selectWorkflowForInstruction(projectDir); - if (!selectedWorkflow) { - info('Cancelled'); - return false; - } - - log.info('Instructing branch via temp clone', { branch, workflow: selectedWorkflow }); - info(`Running instruction on ${branch}...`); - - // 3. Create temp clone for the branch - const clone = createTempCloneForBranch(projectDir, branch); - - try { - // 4. Build instruction with branch context - const branchContext = getBranchContext(projectDir, branch); - const fullInstruction = branchContext - ? `${branchContext}## 追加指示\n${instruction}` - : instruction; - - // 5. Execute task on temp clone - const taskSuccess = await executeTask({ - task: fullInstruction, - cwd: clone.path, - workflowIdentifier: selectedWorkflow, - projectCwd: projectDir, - agentOverrides: options, - }); - - // 6. Auto-commit+push if successful - if (taskSuccess) { - const commitResult = autoCommitAndPush(clone.path, item.taskSlug, projectDir); - if (commitResult.success && commitResult.commitHash) { - info(`Auto-committed & pushed: ${commitResult.commitHash}`); - } else if (!commitResult.success) { - warn(`Auto-commit skipped: ${commitResult.message}`); - } - success(`Instruction completed on ${branch}`); - log.info('Instruction completed', { branch }); - } else { - logError(`Instruction failed on ${branch}`); - log.error('Instruction failed', { branch }); - } - - return taskSuccess; - } finally { - // 7. Always remove temp clone and metadata - removeClone(clone.path); - removeCloneMeta(projectDir, branch); - } -} - /** * Main entry point: list branch-based tasks interactively. */ @@ -373,7 +55,6 @@ export async function listTasks(cwd: string, options?: TaskExecutionOptions): Pr while (branches.length > 0) { const items = buildListItems(cwd, branches, defaultBranch); - // Build selection options const menuOptions = items.map((item, idx) => { const filesSummary = `${item.filesChanged} file${item.filesChanged !== 1 ? 's' : ''} changed`; const description = item.originalInstruction diff --git a/src/commands/management/taskActions.ts b/src/commands/management/taskActions.ts new file mode 100644 index 0000000..9f0a478 --- /dev/null +++ b/src/commands/management/taskActions.ts @@ -0,0 +1,331 @@ +/** + * Individual actions for branch-based tasks. + * + * Provides merge, delete, try-merge, instruct, and diff operations + * for branches listed by the listTasks command. + */ + +import { execFileSync, spawnSync } from 'node:child_process'; +import chalk from 'chalk'; +import { + createTempCloneForBranch, + removeClone, + removeCloneMeta, + cleanupOrphanedClone, +} from '../../task/clone.js'; +import { + detectDefaultBranch, + type BranchListItem, +} from '../../task/branchList.js'; +import { autoCommitAndPush } from '../../task/autoCommit.js'; +import { selectOption, promptInput } from '../../prompt/index.js'; +import { info, success, error as logError, warn, header, blankLine } from '../../utils/ui.js'; +import { createLogger } from '../../utils/debug.js'; +import { getErrorMessage } from '../../utils/error.js'; +import { executeTask, type TaskExecutionOptions } from '../execution/taskExecution.js'; +import { listWorkflows } from '../../config/loaders/workflowLoader.js'; +import { getCurrentWorkflow } from '../../config/paths.js'; +import { DEFAULT_WORKFLOW_NAME } from '../../constants.js'; + +const log = createLogger('list-tasks'); + +/** Actions available for a listed branch */ +export type ListAction = 'diff' | 'instruct' | 'try' | 'merge' | 'delete'; + +/** + * Check if a branch has already been merged into HEAD. + */ +export function isBranchMerged(projectDir: string, branch: string): boolean { + try { + execFileSync('git', ['merge-base', '--is-ancestor', branch, 'HEAD'], { + cwd: projectDir, + encoding: 'utf-8', + stdio: 'pipe', + }); + return true; + } catch { + return false; + } +} + +/** + * Show full diff in an interactive pager (less). + * Falls back to direct output if pager is unavailable. + */ +export function showFullDiff( + cwd: string, + defaultBranch: string, + branch: string, +): void { + try { + const result = spawnSync( + 'git', ['diff', '--color=always', `${defaultBranch}...${branch}`], + { cwd, stdio: ['inherit', 'inherit', 'inherit'], env: { ...process.env, GIT_PAGER: 'less -R' } }, + ); + if (result.status !== 0) { + warn('Could not display diff'); + } + } catch { + warn('Could not display diff'); + } +} + +/** + * Show diff stat for a branch and prompt for an action. + */ +export async function showDiffAndPromptAction( + cwd: string, + defaultBranch: string, + item: BranchListItem, +): Promise { + header(item.info.branch); + if (item.originalInstruction) { + console.log(chalk.dim(` ${item.originalInstruction}`)); + } + blankLine(); + + try { + const stat = execFileSync( + 'git', ['diff', '--stat', `${defaultBranch}...${item.info.branch}`], + { cwd, encoding: 'utf-8', stdio: 'pipe' }, + ); + console.log(stat); + } catch { + warn('Could not generate diff stat'); + } + + const action = await selectOption( + `Action for ${item.info.branch}:`, + [ + { label: 'View diff', value: 'diff', description: 'Show full diff in pager' }, + { label: 'Instruct', value: 'instruct', description: 'Give additional instructions via temp clone' }, + { label: 'Try merge', value: 'try', description: 'Squash merge (stage changes without commit)' }, + { label: 'Merge & cleanup', value: 'merge', description: 'Merge and delete branch' }, + { label: 'Delete', value: 'delete', description: 'Discard changes, delete branch' }, + ], + ); + + return action; +} + +/** + * Try-merge (squash): stage changes from branch without committing. + */ +export function tryMergeBranch(projectDir: string, item: BranchListItem): boolean { + const { branch } = item.info; + + try { + execFileSync('git', ['merge', '--squash', branch], { + cwd: projectDir, + encoding: 'utf-8', + stdio: 'pipe', + }); + + success(`Squash-merged ${branch} (changes staged, not committed)`); + info('Run `git status` to see staged changes, `git commit` to finalize, or `git reset` to undo.'); + log.info('Try-merge (squash) completed', { branch }); + return true; + } catch (err) { + const msg = getErrorMessage(err); + logError(`Squash merge failed: ${msg}`); + logError('You may need to resolve conflicts manually.'); + log.error('Try-merge (squash) failed', { branch, error: msg }); + return false; + } +} + +/** + * Merge & cleanup: if already merged, skip merge and just delete the branch. + */ +export function mergeBranch(projectDir: string, item: BranchListItem): boolean { + const { branch } = item.info; + const alreadyMerged = isBranchMerged(projectDir, branch); + + try { + if (alreadyMerged) { + info(`${branch} is already merged, skipping merge.`); + log.info('Branch already merged, cleanup only', { branch }); + } else { + execFileSync('git', ['merge', branch], { + cwd: projectDir, + encoding: 'utf-8', + stdio: 'pipe', + }); + } + + try { + execFileSync('git', ['branch', '-d', branch], { + cwd: projectDir, + encoding: 'utf-8', + stdio: 'pipe', + }); + } catch { + warn(`Could not delete branch ${branch}. You may delete it manually.`); + } + + cleanupOrphanedClone(projectDir, branch); + + success(`Merged & cleaned up ${branch}`); + log.info('Branch merged & cleaned up', { branch, alreadyMerged }); + return true; + } catch (err) { + const msg = getErrorMessage(err); + logError(`Merge failed: ${msg}`); + logError('You may need to resolve conflicts manually.'); + log.error('Merge & cleanup failed', { branch, error: msg }); + return false; + } +} + +/** + * Delete a branch (discard changes). + */ +export function deleteBranch(projectDir: string, item: BranchListItem): boolean { + const { branch } = item.info; + + try { + execFileSync('git', ['branch', '-D', branch], { + cwd: projectDir, + encoding: 'utf-8', + stdio: 'pipe', + }); + + cleanupOrphanedClone(projectDir, branch); + + success(`Deleted ${branch}`); + log.info('Branch deleted', { branch }); + return true; + } catch (err) { + const msg = getErrorMessage(err); + logError(`Delete failed: ${msg}`); + log.error('Delete failed', { branch, error: msg }); + return false; + } +} + +/** + * Get the workflow to use for instruction. + */ +async function selectWorkflowForInstruction(projectDir: string): Promise { + const availableWorkflows = listWorkflows(projectDir); + const currentWorkflow = getCurrentWorkflow(projectDir); + + if (availableWorkflows.length === 0) { + return DEFAULT_WORKFLOW_NAME; + } + + if (availableWorkflows.length === 1 && availableWorkflows[0]) { + return availableWorkflows[0]; + } + + const options = availableWorkflows.map((name) => ({ + label: name === currentWorkflow ? `${name} (current)` : name, + value: name, + })); + + return await selectOption('Select workflow:', options); +} + +/** + * Get branch context: diff stat and commit log from main branch. + */ +function getBranchContext(projectDir: string, branch: string): string { + const defaultBranch = detectDefaultBranch(projectDir); + const lines: string[] = []; + + try { + const diffStat = execFileSync( + 'git', ['diff', '--stat', `${defaultBranch}...${branch}`], + { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' }, + ).trim(); + if (diffStat) { + lines.push('## 現在の変更内容(mainからの差分)'); + lines.push('```'); + lines.push(diffStat); + lines.push('```'); + } + } catch { + // Ignore errors + } + + try { + const commitLog = execFileSync( + 'git', ['log', '--oneline', `${defaultBranch}..${branch}`], + { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' }, + ).trim(); + if (commitLog) { + lines.push(''); + lines.push('## コミット履歴'); + lines.push('```'); + lines.push(commitLog); + lines.push('```'); + } + } catch { + // Ignore errors + } + + return lines.length > 0 ? lines.join('\n') + '\n\n' : ''; +} + +/** + * Instruct branch: create a temp clone, give additional instructions, + * auto-commit+push, then remove clone. + */ +export async function instructBranch( + projectDir: string, + item: BranchListItem, + options?: TaskExecutionOptions, +): Promise { + const { branch } = item.info; + + const instruction = await promptInput('Enter instruction'); + if (!instruction) { + info('Cancelled'); + return false; + } + + const selectedWorkflow = await selectWorkflowForInstruction(projectDir); + if (!selectedWorkflow) { + info('Cancelled'); + return false; + } + + log.info('Instructing branch via temp clone', { branch, workflow: selectedWorkflow }); + info(`Running instruction on ${branch}...`); + + const clone = createTempCloneForBranch(projectDir, branch); + + try { + const branchContext = getBranchContext(projectDir, branch); + const fullInstruction = branchContext + ? `${branchContext}## 追加指示\n${instruction}` + : instruction; + + const taskSuccess = await executeTask({ + task: fullInstruction, + cwd: clone.path, + workflowIdentifier: selectedWorkflow, + projectCwd: projectDir, + agentOverrides: options, + }); + + if (taskSuccess) { + const commitResult = autoCommitAndPush(clone.path, item.taskSlug, projectDir); + if (commitResult.success && commitResult.commitHash) { + info(`Auto-committed & pushed: ${commitResult.commitHash}`); + } else if (!commitResult.success) { + warn(`Auto-commit skipped: ${commitResult.message}`); + } + success(`Instruction completed on ${branch}`); + log.info('Instruction completed', { branch }); + } else { + logError(`Instruction failed on ${branch}`); + log.error('Instruction failed', { branch }); + } + + return taskSuccess; + } finally { + removeClone(clone.path); + removeCloneMeta(projectDir, branch); + } +} diff --git a/src/config/global/globalConfig.ts b/src/config/global/globalConfig.ts index 28b4b60..2b59403 100644 --- a/src/config/global/globalConfig.ts +++ b/src/config/global/globalConfig.ts @@ -2,6 +2,7 @@ * Global configuration loader * * Manages ~/.takt/config.yaml and project-level debug settings. + * GlobalConfigManager encapsulates the config cache as a singleton. */ import { readFileSync, existsSync, writeFileSync } from 'node:fs'; @@ -23,102 +24,135 @@ function createDefaultGlobalConfig(): GlobalConfig { }; } -/** Module-level cache for global configuration */ -let cachedConfig: GlobalConfig | null = null; +/** + * Manages global configuration loading and caching. + * Singleton — use GlobalConfigManager.getInstance(). + */ +export class GlobalConfigManager { + private static instance: GlobalConfigManager | null = null; + private cachedConfig: GlobalConfig | null = null; -/** Invalidate the cached global configuration (call after mutation) */ -export function invalidateGlobalConfigCache(): void { - cachedConfig = null; -} + private constructor() {} -/** Load global configuration */ -export function loadGlobalConfig(): GlobalConfig { - if (cachedConfig !== null) { - return cachedConfig; - } - const configPath = getGlobalConfigPath(); - if (!existsSync(configPath)) { - const defaultConfig = createDefaultGlobalConfig(); - cachedConfig = defaultConfig; - return defaultConfig; - } - const content = readFileSync(configPath, 'utf-8'); - const raw = parseYaml(content); - const parsed = GlobalConfigSchema.parse(raw); - const config: GlobalConfig = { - language: parsed.language, - trustedDirectories: parsed.trusted_directories, - defaultWorkflow: parsed.default_workflow, - logLevel: parsed.log_level, - provider: parsed.provider, - model: parsed.model, - debug: parsed.debug ? { - enabled: parsed.debug.enabled, - logFile: parsed.debug.log_file, - } : undefined, - worktreeDir: parsed.worktree_dir, - disabledBuiltins: parsed.disabled_builtins, - anthropicApiKey: parsed.anthropic_api_key, - openaiApiKey: parsed.openai_api_key, - pipeline: parsed.pipeline ? { - defaultBranchPrefix: parsed.pipeline.default_branch_prefix, - commitMessageTemplate: parsed.pipeline.commit_message_template, - prBodyTemplate: parsed.pipeline.pr_body_template, - } : undefined, - minimalOutput: parsed.minimal_output, - }; - cachedConfig = config; - return config; -} - -/** Save global configuration */ -export function saveGlobalConfig(config: GlobalConfig): void { - const configPath = getGlobalConfigPath(); - const raw: Record = { - language: config.language, - trusted_directories: config.trustedDirectories, - default_workflow: config.defaultWorkflow, - log_level: config.logLevel, - provider: config.provider, - }; - if (config.model) { - raw.model = config.model; - } - if (config.debug) { - raw.debug = { - enabled: config.debug.enabled, - log_file: config.debug.logFile, - }; - } - if (config.worktreeDir) { - raw.worktree_dir = config.worktreeDir; - } - if (config.disabledBuiltins && config.disabledBuiltins.length > 0) { - raw.disabled_builtins = config.disabledBuiltins; - } - if (config.anthropicApiKey) { - raw.anthropic_api_key = config.anthropicApiKey; - } - if (config.openaiApiKey) { - raw.openai_api_key = config.openaiApiKey; - } - if (config.pipeline) { - const pipelineRaw: Record = {}; - if (config.pipeline.defaultBranchPrefix) pipelineRaw.default_branch_prefix = config.pipeline.defaultBranchPrefix; - if (config.pipeline.commitMessageTemplate) pipelineRaw.commit_message_template = config.pipeline.commitMessageTemplate; - if (config.pipeline.prBodyTemplate) pipelineRaw.pr_body_template = config.pipeline.prBodyTemplate; - if (Object.keys(pipelineRaw).length > 0) { - raw.pipeline = pipelineRaw; + static getInstance(): GlobalConfigManager { + if (!GlobalConfigManager.instance) { + GlobalConfigManager.instance = new GlobalConfigManager(); } + return GlobalConfigManager.instance; } - if (config.minimalOutput !== undefined) { - raw.minimal_output = config.minimalOutput; + + /** Reset singleton for testing */ + static resetInstance(): void { + GlobalConfigManager.instance = null; + } + + /** Invalidate the cached configuration */ + invalidateCache(): void { + this.cachedConfig = null; + } + + /** Load global configuration (cached) */ + load(): GlobalConfig { + if (this.cachedConfig !== null) { + return this.cachedConfig; + } + const configPath = getGlobalConfigPath(); + if (!existsSync(configPath)) { + const defaultConfig = createDefaultGlobalConfig(); + this.cachedConfig = defaultConfig; + return defaultConfig; + } + const content = readFileSync(configPath, 'utf-8'); + const raw = parseYaml(content); + const parsed = GlobalConfigSchema.parse(raw); + const config: GlobalConfig = { + language: parsed.language, + trustedDirectories: parsed.trusted_directories, + defaultWorkflow: parsed.default_workflow, + logLevel: parsed.log_level, + provider: parsed.provider, + model: parsed.model, + debug: parsed.debug ? { + enabled: parsed.debug.enabled, + logFile: parsed.debug.log_file, + } : undefined, + worktreeDir: parsed.worktree_dir, + disabledBuiltins: parsed.disabled_builtins, + anthropicApiKey: parsed.anthropic_api_key, + openaiApiKey: parsed.openai_api_key, + pipeline: parsed.pipeline ? { + defaultBranchPrefix: parsed.pipeline.default_branch_prefix, + commitMessageTemplate: parsed.pipeline.commit_message_template, + prBodyTemplate: parsed.pipeline.pr_body_template, + } : undefined, + minimalOutput: parsed.minimal_output, + }; + this.cachedConfig = config; + return config; + } + + /** Save global configuration to disk and invalidate cache */ + save(config: GlobalConfig): void { + const configPath = getGlobalConfigPath(); + const raw: Record = { + language: config.language, + trusted_directories: config.trustedDirectories, + default_workflow: config.defaultWorkflow, + log_level: config.logLevel, + provider: config.provider, + }; + if (config.model) { + raw.model = config.model; + } + if (config.debug) { + raw.debug = { + enabled: config.debug.enabled, + log_file: config.debug.logFile, + }; + } + if (config.worktreeDir) { + raw.worktree_dir = config.worktreeDir; + } + if (config.disabledBuiltins && config.disabledBuiltins.length > 0) { + raw.disabled_builtins = config.disabledBuiltins; + } + if (config.anthropicApiKey) { + raw.anthropic_api_key = config.anthropicApiKey; + } + if (config.openaiApiKey) { + raw.openai_api_key = config.openaiApiKey; + } + if (config.pipeline) { + const pipelineRaw: Record = {}; + if (config.pipeline.defaultBranchPrefix) pipelineRaw.default_branch_prefix = config.pipeline.defaultBranchPrefix; + if (config.pipeline.commitMessageTemplate) pipelineRaw.commit_message_template = config.pipeline.commitMessageTemplate; + if (config.pipeline.prBodyTemplate) pipelineRaw.pr_body_template = config.pipeline.prBodyTemplate; + if (Object.keys(pipelineRaw).length > 0) { + raw.pipeline = pipelineRaw; + } + } + if (config.minimalOutput !== undefined) { + raw.minimal_output = config.minimalOutput; + } + writeFileSync(configPath, stringifyYaml(raw), 'utf-8'); + this.invalidateCache(); } - writeFileSync(configPath, stringifyYaml(raw), 'utf-8'); - invalidateGlobalConfigCache(); } -/** Get list of disabled builtin names */ +// ---- Backward-compatible module-level functions ---- + +export function invalidateGlobalConfigCache(): void { + GlobalConfigManager.getInstance().invalidateCache(); +} + +export function loadGlobalConfig(): GlobalConfig { + return GlobalConfigManager.getInstance().load(); +} + +export function saveGlobalConfig(config: GlobalConfig): void { + GlobalConfigManager.getInstance().save(config); +} + export function getDisabledBuiltins(): string[] { try { const config = loadGlobalConfig(); @@ -128,7 +162,6 @@ export function getDisabledBuiltins(): string[] { } } -/** Get current language setting */ export function getLanguage(): Language { try { const config = loadGlobalConfig(); @@ -138,21 +171,18 @@ export function getLanguage(): Language { } } -/** Set language setting */ export function setLanguage(language: Language): void { const config = loadGlobalConfig(); config.language = language; saveGlobalConfig(config); } -/** Set provider setting */ export function setProvider(provider: 'claude' | 'codex'): void { const config = loadGlobalConfig(); config.provider = provider; saveGlobalConfig(config); } -/** Add a trusted directory */ export function addTrustedDirectory(dir: string): void { const config = loadGlobalConfig(); const resolvedDir = join(dir); @@ -162,7 +192,6 @@ export function addTrustedDirectory(dir: string): void { } } -/** Check if a directory is trusted */ export function isDirectoryTrusted(dir: string): boolean { const config = loadGlobalConfig(); const resolvedDir = join(dir); diff --git a/src/config/global/index.ts b/src/config/global/index.ts index 4e991c9..b73b2e5 100644 --- a/src/config/global/index.ts +++ b/src/config/global/index.ts @@ -3,6 +3,7 @@ */ export { + GlobalConfigManager, invalidateGlobalConfigCache, loadGlobalConfig, saveGlobalConfig, diff --git a/src/config/loaders/workflowLoader.ts b/src/config/loaders/workflowLoader.ts index 0f9784e..b8834b6 100644 --- a/src/config/loaders/workflowLoader.ts +++ b/src/config/loaders/workflowLoader.ts @@ -1,449 +1,20 @@ /** - * Workflow configuration loader + * Workflow configuration loader — re-export hub. * - * Loads workflows with the following priority: - * 1. Path-based input (absolute, relative, or home-dir) → load directly from file - * 2. Project-local workflows: .takt/workflows/{name}.yaml - * 3. User workflows: ~/.takt/workflows/{name}.yaml - * 4. Builtin workflows: resources/global/{lang}/workflows/{name}.yaml + * Implementations have been split into: + * - workflowParser.ts: YAML parsing, step/rule normalization + * - workflowResolver.ts: 3-layer resolution (builtin → user → project-local) */ -import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'; -import { join, dirname, basename, resolve, isAbsolute } from 'node:path'; -import { homedir } from 'node:os'; -import { parse as parseYaml } from 'yaml'; -import { WorkflowConfigRawSchema } from '../../models/schemas.js'; -import type { WorkflowConfig, WorkflowStep, WorkflowRule, ReportConfig, ReportObjectConfig } from '../../models/types.js'; -import { getGlobalWorkflowsDir, getBuiltinWorkflowsDir, getProjectConfigDir } from '../paths.js'; -import { getLanguage, getDisabledBuiltins } from '../global/globalConfig.js'; +// Parser exports +export { normalizeWorkflowConfig, loadWorkflowFromFile } from './workflowParser.js'; -/** Get builtin workflow by name */ -export function getBuiltinWorkflow(name: string): WorkflowConfig | null { - const lang = getLanguage(); - const disabled = getDisabledBuiltins(); - if (disabled.includes(name)) return null; - - const builtinDir = getBuiltinWorkflowsDir(lang); - const yamlPath = join(builtinDir, `${name}.yaml`); - if (existsSync(yamlPath)) { - return loadWorkflowFromFile(yamlPath); - } - return null; -} - -/** - * Resolve agent path from workflow specification. - * - Relative path (./agent.md): relative to workflow directory - * - Absolute path (/path/to/agent.md or ~/...): use as-is - */ -function resolveAgentPathForWorkflow(agentSpec: string, workflowDir: string): string { - // Relative path (starts with ./) - if (agentSpec.startsWith('./')) { - return join(workflowDir, agentSpec.slice(2)); - } - - // Home directory expansion - if (agentSpec.startsWith('~')) { - const homedir = process.env.HOME || process.env.USERPROFILE || ''; - return join(homedir, agentSpec.slice(1)); - } - - // Absolute path - if (agentSpec.startsWith('/')) { - return agentSpec; - } - - // Fallback: treat as relative to workflow directory - return join(workflowDir, agentSpec); -} - -/** - * Extract display name from agent path. - * e.g., "~/.takt/agents/default/coder.md" -> "coder" - */ -function extractAgentDisplayName(agentPath: string): string { - // Get the filename without extension - const filename = basename(agentPath, '.md'); - return filename; -} - -/** - * Resolve a string value that may be a file path. - * If the value ends with .md and the file exists (resolved relative to workflowDir), - * read and return the file contents. Otherwise return the value as-is. - */ -function resolveContentPath(value: string | undefined, workflowDir: string): string | undefined { - if (value == null) return undefined; - if (value.endsWith('.md')) { - // Resolve path relative to workflow directory - let resolvedPath = value; - if (value.startsWith('./')) { - resolvedPath = join(workflowDir, value.slice(2)); - } else if (value.startsWith('~')) { - const homedir = process.env.HOME || process.env.USERPROFILE || ''; - resolvedPath = join(homedir, value.slice(1)); - } else if (!value.startsWith('/')) { - resolvedPath = join(workflowDir, value); - } - if (existsSync(resolvedPath)) { - return readFileSync(resolvedPath, 'utf-8'); - } - } - return value; -} - -/** - * Check if a raw report value is the object form (has 'name' property). - */ -function isReportObject(raw: unknown): raw is { name: string; order?: string; format?: string } { - return typeof raw === 'object' && raw !== null && !Array.isArray(raw) && 'name' in raw; -} - -/** - * Normalize the raw report field from YAML into internal format. - * - * YAML formats: - * report: "00-plan.md" → string (single file) - * report: → ReportConfig[] (multiple files) - * - Scope: 01-scope.md - * - Decisions: 02-decisions.md - * report: → ReportObjectConfig (object form) - * name: 00-plan.md - * order: ... - * format: ... - * - * Array items are parsed as single-key objects: [{Scope: "01-scope.md"}, ...] - */ -function normalizeReport( - raw: string | Record[] | { name: string; order?: string; format?: string } | undefined, - workflowDir: string, -): string | ReportConfig[] | ReportObjectConfig | undefined { - if (raw == null) return undefined; - if (typeof raw === 'string') return raw; - if (isReportObject(raw)) { - return { - name: raw.name, - order: resolveContentPath(raw.order, workflowDir), - format: resolveContentPath(raw.format, workflowDir), - }; - } - // Convert [{Scope: "01-scope.md"}, ...] to [{label: "Scope", path: "01-scope.md"}, ...] - return (raw as Record[]).flatMap((entry) => - Object.entries(entry).map(([label, path]) => ({ label, path })), - ); -} - -/** Regex to detect ai("...") condition expressions */ -const AI_CONDITION_REGEX = /^ai\("(.+)"\)$/; - -/** Regex to detect all("...")/any("...") aggregate condition expressions */ -const AGGREGATE_CONDITION_REGEX = /^(all|any)\("(.+)"\)$/; - -/** - * Parse a rule's condition for ai() and all()/any() expressions. - * - `ai("text")` → sets isAiCondition and aiConditionText - * - `all("text")` / `any("text")` → sets isAggregateCondition, aggregateType, aggregateConditionText - */ -function normalizeRule(r: { condition: string; next: string; appendix?: string }): WorkflowRule { - const aiMatch = r.condition.match(AI_CONDITION_REGEX); - if (aiMatch?.[1]) { - return { - condition: r.condition, - next: r.next, - appendix: r.appendix, - isAiCondition: true, - aiConditionText: aiMatch[1], - }; - } - - const aggMatch = r.condition.match(AGGREGATE_CONDITION_REGEX); - if (aggMatch?.[1] && aggMatch[2]) { - return { - condition: r.condition, - next: r.next, - appendix: r.appendix, - isAggregateCondition: true, - aggregateType: aggMatch[1] as 'all' | 'any', - aggregateConditionText: aggMatch[2], - }; - } - - return { - condition: r.condition, - next: r.next, - appendix: r.appendix, - }; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type RawStep = any; - -/** - * Normalize a raw step into internal WorkflowStep format. - */ -function normalizeStepFromRaw(step: RawStep, workflowDir: string): WorkflowStep { - const rules: WorkflowRule[] | undefined = step.rules?.map(normalizeRule); - const agentSpec: string = step.agent ?? ''; - - const result: WorkflowStep = { - name: step.name, - agent: agentSpec, - agentDisplayName: step.agent_name || (agentSpec ? extractAgentDisplayName(agentSpec) : step.name), - agentPath: agentSpec ? resolveAgentPathForWorkflow(agentSpec, workflowDir) : undefined, - allowedTools: step.allowed_tools, - provider: step.provider, - model: step.model, - permissionMode: step.permission_mode, - edit: step.edit, - instructionTemplate: resolveContentPath(step.instruction_template, workflowDir) || step.instruction || '{task}', - rules, - report: normalizeReport(step.report, workflowDir), - passPreviousResponse: step.pass_previous_response ?? true, - }; - - if (step.parallel && step.parallel.length > 0) { - result.parallel = step.parallel.map((sub: RawStep) => normalizeStepFromRaw(sub, workflowDir)); - } - - return result; -} - -/** - * Convert raw YAML workflow config to internal format. - * Agent paths are resolved relative to the workflow directory. - */ -function normalizeWorkflowConfig(raw: unknown, workflowDir: string): WorkflowConfig { - const parsed = WorkflowConfigRawSchema.parse(raw); - - const steps: WorkflowStep[] = parsed.steps.map((step) => - normalizeStepFromRaw(step, workflowDir), - ); - - return { - name: parsed.name, - description: parsed.description, - steps, - initialStep: parsed.initial_step || steps[0]?.name || '', - maxIterations: parsed.max_iterations, - answerAgent: parsed.answer_agent, - }; -} - -/** - * Load a workflow from a YAML file. - * @param filePath Path to the workflow YAML file - */ -function loadWorkflowFromFile(filePath: string): WorkflowConfig { - if (!existsSync(filePath)) { - throw new Error(`Workflow file not found: ${filePath}`); - } - const content = readFileSync(filePath, 'utf-8'); - const raw = parseYaml(content); - const workflowDir = dirname(filePath); - return normalizeWorkflowConfig(raw, workflowDir); -} - -/** - * Resolve a path that may be relative, absolute, or home-directory-relative. - * @param pathInput Path to resolve - * @param basePath Base directory for relative paths - * @returns Absolute resolved path - */ -function resolvePath(pathInput: string, basePath: string): string { - // Home directory expansion - if (pathInput.startsWith('~')) { - const home = homedir(); - return resolve(home, pathInput.slice(1).replace(/^\//, '')); - } - - // Absolute path - if (isAbsolute(pathInput)) { - return pathInput; - } - - // Relative path - return resolve(basePath, pathInput); -} - -/** - * Load workflow from a file path. - * Called internally by loadWorkflowByIdentifier when the identifier is detected as a path. - * - * @param filePath Path to workflow file (absolute, relative, or home-dir prefixed with ~) - * @param basePath Base directory for resolving relative paths - * @returns WorkflowConfig or null if file not found - */ -function loadWorkflowFromPath( - filePath: string, - basePath: string -): WorkflowConfig | null { - const resolvedPath = resolvePath(filePath, basePath); - - if (!existsSync(resolvedPath)) { - return null; - } - - return loadWorkflowFromFile(resolvedPath); -} - -/** - * Load workflow by name (name-based loading only, no path detection). - * - * Priority: - * 1. Project-local workflows → .takt/workflows/{name}.yaml - * 2. User workflows → ~/.takt/workflows/{name}.yaml - * 3. Builtin workflows → resources/global/{lang}/workflows/{name}.yaml - * - * @param name Workflow name (not a file path) - * @param projectCwd Project root directory (for project-local workflow resolution) - */ -export function loadWorkflow( - name: string, - projectCwd: string -): WorkflowConfig | null { - // 1. Project-local workflow (.takt/workflows/{name}.yaml) - const projectWorkflowsDir = join(getProjectConfigDir(projectCwd), 'workflows'); - const projectWorkflowPath = join(projectWorkflowsDir, `${name}.yaml`); - if (existsSync(projectWorkflowPath)) { - return loadWorkflowFromFile(projectWorkflowPath); - } - - // 2. User workflow (~/.takt/workflows/{name}.yaml) - const globalWorkflowsDir = getGlobalWorkflowsDir(); - const workflowYamlPath = join(globalWorkflowsDir, `${name}.yaml`); - if (existsSync(workflowYamlPath)) { - return loadWorkflowFromFile(workflowYamlPath); - } - - // 3. Builtin fallback - return getBuiltinWorkflow(name); -} - -/** - * Load all workflows with descriptions (for switch command). - * - * Priority (later entries override earlier): - * 1. Builtin workflows - * 2. User workflows (~/.takt/workflows/) - * 3. Project-local workflows (.takt/workflows/) - */ -export function loadAllWorkflows(cwd: string): Map { - const workflows = new Map(); - const disabled = getDisabledBuiltins(); - - // 1. Builtin workflows (lowest priority) - const lang = getLanguage(); - const builtinDir = getBuiltinWorkflowsDir(lang); - loadWorkflowsFromDir(builtinDir, workflows, disabled); - - // 2. User workflows (overrides builtins) - const globalWorkflowsDir = getGlobalWorkflowsDir(); - loadWorkflowsFromDir(globalWorkflowsDir, workflows); - - // 3. Project-local workflows (highest priority) - const projectWorkflowsDir = join(getProjectConfigDir(cwd), 'workflows'); - loadWorkflowsFromDir(projectWorkflowsDir, workflows); - - return workflows; -} - -/** Load workflow files from a directory into a Map (later calls override earlier entries) */ -function loadWorkflowsFromDir( - dir: string, - target: Map, - disabled?: string[], -): void { - if (!existsSync(dir)) return; - for (const entry of readdirSync(dir)) { - if (!entry.endsWith('.yaml') && !entry.endsWith('.yml')) continue; - const entryPath = join(dir, entry); - if (!statSync(entryPath).isFile()) continue; - const workflowName = entry.replace(/\.ya?ml$/, ''); - if (disabled?.includes(workflowName)) continue; - try { - target.set(workflowName, loadWorkflowFromFile(entryPath)); - } catch { - // Skip invalid workflows - } - } -} - -/** - * List available workflow names (builtin + user + project-local, excluding disabled). - * - * @param cwd Project root directory (used to scan project-local .takt/workflows/). - */ -export function listWorkflows(cwd: string): string[] { - const workflows = new Set(); - const disabled = getDisabledBuiltins(); - - // 1. Builtin workflows - const lang = getLanguage(); - const builtinDir = getBuiltinWorkflowsDir(lang); - scanWorkflowDir(builtinDir, workflows, disabled); - - // 2. User workflows - const globalWorkflowsDir = getGlobalWorkflowsDir(); - scanWorkflowDir(globalWorkflowsDir, workflows); - - // 3. Project-local workflows - const projectWorkflowsDir = join(getProjectConfigDir(cwd), 'workflows'); - scanWorkflowDir(projectWorkflowsDir, workflows); - - return Array.from(workflows).sort(); -} - -/** - * Check if a workflow identifier looks like a file path (vs a workflow name). - * - * Path indicators: - * - Starts with `/` (absolute path) - * - Starts with `~` (home directory) - * - Starts with `./` or `../` (relative path) - * - Ends with `.yaml` or `.yml` (file extension) - */ -export function isWorkflowPath(identifier: string): boolean { - return ( - identifier.startsWith('/') || - identifier.startsWith('~') || - identifier.startsWith('./') || - identifier.startsWith('../') || - identifier.endsWith('.yaml') || - identifier.endsWith('.yml') - ); -} - -/** - * Load workflow by identifier (auto-detects name vs path). - * - * If the identifier looks like a path (see isWorkflowPath), loads from file. - * Otherwise, loads by name with the standard priority chain: - * project-local → user → builtin. - * - * @param identifier Workflow name or file path - * @param projectCwd Project root directory (for project-local resolution and relative path base) - */ -export function loadWorkflowByIdentifier( - identifier: string, - projectCwd: string -): WorkflowConfig | null { - if (isWorkflowPath(identifier)) { - return loadWorkflowFromPath(identifier, projectCwd); - } - return loadWorkflow(identifier, projectCwd); -} - -/** Scan a directory for .yaml/.yml files and add names to the set */ -function scanWorkflowDir(dir: string, target: Set, disabled?: string[]): void { - if (!existsSync(dir)) return; - for (const entry of readdirSync(dir)) { - if (!entry.endsWith('.yaml') && !entry.endsWith('.yml')) continue; - - const entryPath = join(dir, entry); - if (statSync(entryPath).isFile()) { - const workflowName = entry.replace(/\.ya?ml$/, ''); - if (disabled?.includes(workflowName)) continue; - target.add(workflowName); - } - } -} +// Resolver exports (public API) +export { + getBuiltinWorkflow, + loadWorkflow, + isWorkflowPath, + loadWorkflowByIdentifier, + loadAllWorkflows, + listWorkflows, +} from './workflowResolver.js'; diff --git a/src/config/loaders/workflowParser.ts b/src/config/loaders/workflowParser.ts new file mode 100644 index 0000000..ed8a3d6 --- /dev/null +++ b/src/config/loaders/workflowParser.ts @@ -0,0 +1,197 @@ +/** + * Workflow YAML parsing and normalization. + * + * Converts raw YAML structures into internal WorkflowConfig format, + * resolving agent paths, content paths, and rule conditions. + */ + +import { readFileSync, existsSync } from 'node:fs'; +import { join, dirname, basename } from 'node:path'; +import { parse as parseYaml } from 'yaml'; +import type { z } from 'zod'; +import { WorkflowConfigRawSchema, WorkflowStepRawSchema } from '../../models/schemas.js'; +import type { WorkflowConfig, WorkflowStep, WorkflowRule, ReportConfig, ReportObjectConfig } from '../../models/types.js'; + +/** Parsed step type from Zod schema (replaces `any`) */ +type RawStep = z.output; + +/** + * Resolve agent path from workflow specification. + * - Relative path (./agent.md): relative to workflow directory + * - Absolute path (/path/to/agent.md or ~/...): use as-is + */ +function resolveAgentPathForWorkflow(agentSpec: string, workflowDir: string): string { + if (agentSpec.startsWith('./')) { + return join(workflowDir, agentSpec.slice(2)); + } + if (agentSpec.startsWith('~')) { + const homedir = process.env.HOME || process.env.USERPROFILE || ''; + return join(homedir, agentSpec.slice(1)); + } + if (agentSpec.startsWith('/')) { + return agentSpec; + } + return join(workflowDir, agentSpec); +} + +/** + * Extract display name from agent path. + * e.g., "~/.takt/agents/default/coder.md" -> "coder" + */ +function extractAgentDisplayName(agentPath: string): string { + return basename(agentPath, '.md'); +} + +/** + * Resolve a string value that may be a file path. + * If the value ends with .md and the file exists (resolved relative to workflowDir), + * read and return the file contents. Otherwise return the value as-is. + */ +function resolveContentPath(value: string | undefined, workflowDir: string): string | undefined { + if (value == null) return undefined; + if (value.endsWith('.md')) { + let resolvedPath = value; + if (value.startsWith('./')) { + resolvedPath = join(workflowDir, value.slice(2)); + } else if (value.startsWith('~')) { + const homedir = process.env.HOME || process.env.USERPROFILE || ''; + resolvedPath = join(homedir, value.slice(1)); + } else if (!value.startsWith('/')) { + resolvedPath = join(workflowDir, value); + } + if (existsSync(resolvedPath)) { + return readFileSync(resolvedPath, 'utf-8'); + } + } + return value; +} + +/** Check if a raw report value is the object form (has 'name' property). */ +function isReportObject(raw: unknown): raw is { name: string; order?: string; format?: string } { + return typeof raw === 'object' && raw !== null && !Array.isArray(raw) && 'name' in raw; +} + +/** + * Normalize the raw report field from YAML into internal format. + */ +function normalizeReport( + raw: string | Record[] | { name: string; order?: string; format?: string } | undefined, + workflowDir: string, +): string | ReportConfig[] | ReportObjectConfig | undefined { + if (raw == null) return undefined; + if (typeof raw === 'string') return raw; + if (isReportObject(raw)) { + return { + name: raw.name, + order: resolveContentPath(raw.order, workflowDir), + format: resolveContentPath(raw.format, workflowDir), + }; + } + return (raw as Record[]).flatMap((entry) => + Object.entries(entry).map(([label, path]) => ({ label, path })), + ); +} + +/** Regex to detect ai("...") condition expressions */ +const AI_CONDITION_REGEX = /^ai\("(.+)"\)$/; + +/** Regex to detect all("...")/any("...") aggregate condition expressions */ +const AGGREGATE_CONDITION_REGEX = /^(all|any)\("(.+)"\)$/; + +/** + * Parse a rule's condition for ai() and all()/any() expressions. + */ +function normalizeRule(r: { condition: string; next?: string; appendix?: string }): WorkflowRule { + const next = r.next ?? ''; + const aiMatch = r.condition.match(AI_CONDITION_REGEX); + if (aiMatch?.[1]) { + return { + condition: r.condition, + next, + appendix: r.appendix, + isAiCondition: true, + aiConditionText: aiMatch[1], + }; + } + + const aggMatch = r.condition.match(AGGREGATE_CONDITION_REGEX); + if (aggMatch?.[1] && aggMatch[2]) { + return { + condition: r.condition, + next, + appendix: r.appendix, + isAggregateCondition: true, + aggregateType: aggMatch[1] as 'all' | 'any', + aggregateConditionText: aggMatch[2], + }; + } + + return { + condition: r.condition, + next, + appendix: r.appendix, + }; +} + +/** Normalize a raw step into internal WorkflowStep format. */ +function normalizeStepFromRaw(step: RawStep, workflowDir: string): WorkflowStep { + const rules: WorkflowRule[] | undefined = step.rules?.map(normalizeRule); + const agentSpec: string = step.agent ?? ''; + + const result: WorkflowStep = { + name: step.name, + agent: agentSpec, + agentDisplayName: step.agent_name || (agentSpec ? extractAgentDisplayName(agentSpec) : step.name), + agentPath: agentSpec ? resolveAgentPathForWorkflow(agentSpec, workflowDir) : undefined, + allowedTools: step.allowed_tools, + provider: step.provider, + model: step.model, + permissionMode: step.permission_mode, + edit: step.edit, + instructionTemplate: resolveContentPath(step.instruction_template, workflowDir) || step.instruction || '{task}', + rules, + report: normalizeReport(step.report, workflowDir), + passPreviousResponse: step.pass_previous_response ?? true, + }; + + if (step.parallel && step.parallel.length > 0) { + result.parallel = step.parallel.map((sub: RawStep) => normalizeStepFromRaw(sub, workflowDir)); + } + + return result; +} + +/** + * Convert raw YAML workflow config to internal format. + * Agent paths are resolved relative to the workflow directory. + */ +export function normalizeWorkflowConfig(raw: unknown, workflowDir: string): WorkflowConfig { + const parsed = WorkflowConfigRawSchema.parse(raw); + + const steps: WorkflowStep[] = parsed.steps.map((step) => + normalizeStepFromRaw(step, workflowDir), + ); + + return { + name: parsed.name, + description: parsed.description, + steps, + initialStep: parsed.initial_step || steps[0]?.name || '', + maxIterations: parsed.max_iterations, + answerAgent: parsed.answer_agent, + }; +} + +/** + * Load a workflow from a YAML file. + * @param filePath Path to the workflow YAML file + */ +export function loadWorkflowFromFile(filePath: string): WorkflowConfig { + if (!existsSync(filePath)) { + throw new Error(`Workflow file not found: ${filePath}`); + } + const content = readFileSync(filePath, 'utf-8'); + const raw = parseYaml(content); + const workflowDir = dirname(filePath); + return normalizeWorkflowConfig(raw, workflowDir); +} diff --git a/src/config/loaders/workflowResolver.ts b/src/config/loaders/workflowResolver.ts new file mode 100644 index 0000000..53a159c --- /dev/null +++ b/src/config/loaders/workflowResolver.ts @@ -0,0 +1,189 @@ +/** + * Workflow resolution — 3-layer lookup logic. + * + * Resolves workflow names and paths to concrete WorkflowConfig objects, + * using the priority chain: project-local → user → builtin. + */ + +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { join, resolve, isAbsolute } from 'node:path'; +import { homedir } from 'node:os'; +import type { WorkflowConfig } from '../../models/types.js'; +import { getGlobalWorkflowsDir, getBuiltinWorkflowsDir, getProjectConfigDir } from '../paths.js'; +import { getLanguage, getDisabledBuiltins } from '../global/globalConfig.js'; +import { createLogger } from '../../utils/debug.js'; +import { getErrorMessage } from '../../utils/error.js'; +import { loadWorkflowFromFile } from './workflowParser.js'; + +const log = createLogger('workflow-resolver'); + +/** Get builtin workflow by name */ +export function getBuiltinWorkflow(name: string): WorkflowConfig | null { + const lang = getLanguage(); + const disabled = getDisabledBuiltins(); + if (disabled.includes(name)) return null; + + const builtinDir = getBuiltinWorkflowsDir(lang); + const yamlPath = join(builtinDir, `${name}.yaml`); + if (existsSync(yamlPath)) { + return loadWorkflowFromFile(yamlPath); + } + return null; +} + +/** + * Resolve a path that may be relative, absolute, or home-directory-relative. + */ +function resolvePath(pathInput: string, basePath: string): string { + if (pathInput.startsWith('~')) { + const home = homedir(); + return resolve(home, pathInput.slice(1).replace(/^\//, '')); + } + if (isAbsolute(pathInput)) { + return pathInput; + } + return resolve(basePath, pathInput); +} + +/** + * Load workflow from a file path. + */ +function loadWorkflowFromPath( + filePath: string, + basePath: string, +): WorkflowConfig | null { + const resolvedPath = resolvePath(filePath, basePath); + if (!existsSync(resolvedPath)) { + return null; + } + return loadWorkflowFromFile(resolvedPath); +} + +/** + * Load workflow by name (name-based loading only, no path detection). + * + * Priority: + * 1. Project-local workflows → .takt/workflows/{name}.yaml + * 2. User workflows → ~/.takt/workflows/{name}.yaml + * 3. Builtin workflows → resources/global/{lang}/workflows/{name}.yaml + */ +export function loadWorkflow( + name: string, + projectCwd: string, +): WorkflowConfig | null { + const projectWorkflowsDir = join(getProjectConfigDir(projectCwd), 'workflows'); + const projectWorkflowPath = join(projectWorkflowsDir, `${name}.yaml`); + if (existsSync(projectWorkflowPath)) { + return loadWorkflowFromFile(projectWorkflowPath); + } + + const globalWorkflowsDir = getGlobalWorkflowsDir(); + const workflowYamlPath = join(globalWorkflowsDir, `${name}.yaml`); + if (existsSync(workflowYamlPath)) { + return loadWorkflowFromFile(workflowYamlPath); + } + + return getBuiltinWorkflow(name); +} + +/** + * Check if a workflow identifier looks like a file path (vs a workflow name). + */ +export function isWorkflowPath(identifier: string): boolean { + return ( + identifier.startsWith('/') || + identifier.startsWith('~') || + identifier.startsWith('./') || + identifier.startsWith('../') || + identifier.endsWith('.yaml') || + identifier.endsWith('.yml') + ); +} + +/** + * Load workflow by identifier (auto-detects name vs path). + */ +export function loadWorkflowByIdentifier( + identifier: string, + projectCwd: string, +): WorkflowConfig | null { + if (isWorkflowPath(identifier)) { + return loadWorkflowFromPath(identifier, projectCwd); + } + return loadWorkflow(identifier, projectCwd); +} + +/** Entry for a workflow file found in a directory */ +interface WorkflowDirEntry { + name: string; + path: string; +} + +/** + * Iterate workflow YAML files in a directory, yielding name and path. + * Shared by both loadAllWorkflows and listWorkflows to avoid DRY violation. + */ +function* iterateWorkflowDir( + dir: string, + disabled?: string[], +): Generator { + if (!existsSync(dir)) return; + for (const entry of readdirSync(dir)) { + if (!entry.endsWith('.yaml') && !entry.endsWith('.yml')) continue; + const entryPath = join(dir, entry); + if (!statSync(entryPath).isFile()) continue; + const workflowName = entry.replace(/\.ya?ml$/, ''); + if (disabled?.includes(workflowName)) continue; + yield { name: workflowName, path: entryPath }; + } +} + +/** Get the 3-layer directory list (builtin → user → project-local) */ +function getWorkflowDirs(cwd: string): { dir: string; disabled?: string[] }[] { + const disabled = getDisabledBuiltins(); + const lang = getLanguage(); + return [ + { dir: getBuiltinWorkflowsDir(lang), disabled }, + { dir: getGlobalWorkflowsDir() }, + { dir: join(getProjectConfigDir(cwd), 'workflows') }, + ]; +} + +/** + * Load all workflows with descriptions (for switch command). + * + * Priority (later entries override earlier): + * 1. Builtin workflows + * 2. User workflows (~/.takt/workflows/) + * 3. Project-local workflows (.takt/workflows/) + */ +export function loadAllWorkflows(cwd: string): Map { + const workflows = new Map(); + + for (const { dir, disabled } of getWorkflowDirs(cwd)) { + for (const entry of iterateWorkflowDir(dir, disabled)) { + try { + workflows.set(entry.name, loadWorkflowFromFile(entry.path)); + } catch (err) { + log.debug('Skipping invalid workflow file', { path: entry.path, error: getErrorMessage(err) }); + } + } + } + + return workflows; +} + +/** + * List available workflow names (builtin + user + project-local, excluding disabled). + */ +export function listWorkflows(cwd: string): string[] { + const workflows = new Set(); + + for (const { dir, disabled } of getWorkflowDirs(cwd)) { + for (const entry of iterateWorkflowDir(dir, disabled)) { + workflows.add(entry.name); + } + } + + return Array.from(workflows).sort(); +} diff --git a/src/prompt/confirm.ts b/src/prompt/confirm.ts new file mode 100644 index 0000000..6efbfd9 --- /dev/null +++ b/src/prompt/confirm.ts @@ -0,0 +1,101 @@ +/** + * Confirmation and text input prompts. + * + * Provides yes/no confirmation, single-line text input, + * and multiline text input from readable streams. + */ + +import * as readline from 'node:readline'; +import chalk from 'chalk'; + +/** + * Prompt user for simple text input + * @returns User input or null if cancelled + */ +export async function promptInput(message: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question(chalk.green(message + ': '), (answer) => { + rl.close(); + + const trimmed = answer.trim(); + if (!trimmed) { + resolve(null); + return; + } + + resolve(trimmed); + }); + }); +} + +/** + * Read multiline input from a readable stream. + * An empty line finishes input. If the first line is empty, returns null. + * Exported for testing. + */ +export function readMultilineFromStream(input: NodeJS.ReadableStream): Promise { + const lines: string[] = []; + const rl = readline.createInterface({ input }); + + return new Promise((resolve) => { + let resolved = false; + + rl.on('line', (line) => { + if (line === '' && lines.length > 0) { + resolved = true; + rl.close(); + const result = lines.join('\n').trim(); + resolve(result || null); + return; + } + + if (line === '' && lines.length === 0) { + resolved = true; + rl.close(); + resolve(null); + return; + } + + lines.push(line); + }); + + rl.on('close', () => { + if (!resolved) { + resolve(lines.length > 0 ? lines.join('\n').trim() : null); + } + }); + }); +} + +/** + * Prompt user for yes/no confirmation + * @returns true for yes, false for no + */ +export async function confirm(message: string, defaultYes = true): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const hint = defaultYes ? '[Y/n]' : '[y/N]'; + + return new Promise((resolve) => { + rl.question(chalk.green(`${message} ${hint}: `), (answer) => { + rl.close(); + + const trimmed = answer.trim().toLowerCase(); + + if (!trimmed) { + resolve(defaultYes); + return; + } + + resolve(trimmed === 'y' || trimmed === 'yes'); + }); + }); +} diff --git a/src/prompt/index.ts b/src/prompt/index.ts index 74ff0bc..caa56cc 100644 --- a/src/prompt/index.ts +++ b/src/prompt/index.ts @@ -1,402 +1,23 @@ /** - * Interactive prompts for CLI + * Interactive prompts for CLI — re-export hub. * - * Provides cursor-based selection menus using arrow keys. - * Users navigate with ↑/↓ keys and confirm with Enter. + * Implementations have been split into: + * - select.ts: Cursor-based menu selection (arrow key navigation) + * - confirm.ts: Yes/no confirmation and text input prompts */ -import * as readline from 'node:readline'; -import chalk from 'chalk'; -import { truncateText } from '../utils/text.js'; +export { + type SelectOptionItem, + renderMenu, + countRenderedLines, + type KeyInputResult, + handleKeyInput, + selectOption, + selectOptionWithDefault, +} from './select.js'; -/** Option type for selectOption */ -export interface SelectOptionItem { - label: string; - value: T; - description?: string; - details?: string[]; -} - -/** - * Render the menu options to the terminal. - * Writes directly to stdout using ANSI escape codes. - * Labels are truncated to fit within the terminal width. - * Exported for testing. - */ -export function renderMenu( - options: SelectOptionItem[], - selectedIndex: number, - hasCancelOption: boolean -): string[] { - const maxWidth = process.stdout.columns || 80; - // Prefix " ❯ " = 4 visible columns (2 spaces + cursor + space) - const labelPrefix = 4; - // Description prefix " " = 5 visible columns - const descPrefix = 5; - // Detail prefix " • " = 9 visible columns - const detailPrefix = 9; - - const lines: string[] = []; - - for (let i = 0; i < options.length; i++) { - const opt = options[i]!; - const isSelected = i === selectedIndex; - const cursor = isSelected ? chalk.cyan('❯') : ' '; - const truncatedLabel = truncateText(opt.label, maxWidth - labelPrefix); - const label = isSelected ? chalk.cyan.bold(truncatedLabel) : truncatedLabel; - lines.push(` ${cursor} ${label}`); - - if (opt.description) { - const truncatedDesc = truncateText(opt.description, maxWidth - descPrefix); - lines.push(chalk.gray(` ${truncatedDesc}`)); - } - if (opt.details && opt.details.length > 0) { - for (const detail of opt.details) { - const truncatedDetail = truncateText(detail, maxWidth - detailPrefix); - lines.push(chalk.dim(` • ${truncatedDetail}`)); - } - } - } - - if (hasCancelOption) { - const isCancelSelected = selectedIndex === options.length; - const cursor = isCancelSelected ? chalk.cyan('❯') : ' '; - const label = isCancelSelected ? chalk.cyan.bold('Cancel') : chalk.gray('Cancel'); - lines.push(` ${cursor} ${label}`); - } - - return lines; -} - -/** - * Count total rendered lines for a set of options. - * Exported for testing. - */ -export function countRenderedLines( - options: SelectOptionItem[], - hasCancelOption: boolean -): number { - let count = 0; - for (const opt of options) { - count++; // main label line - if (opt.description) count++; - if (opt.details) count += opt.details.length; - } - if (hasCancelOption) count++; - return count; -} - -/** Result of handling a key input */ -export type KeyInputResult = - | { action: 'move'; newIndex: number } - | { action: 'confirm'; selectedIndex: number } - | { action: 'cancel'; cancelIndex: number } - | { action: 'exit' } - | { action: 'none' }; - -/** - * Pure function for key input state transitions. - * Maps a key string to an action and new state. - * Exported for testing. - */ -export function handleKeyInput( - key: string, - currentIndex: number, - totalItems: number, - hasCancelOption: boolean, - optionCount: number -): KeyInputResult { - // Up arrow or vim 'k' - if (key === '\x1B[A' || key === 'k') { - return { action: 'move', newIndex: (currentIndex - 1 + totalItems) % totalItems }; - } - // Down arrow or vim 'j' - if (key === '\x1B[B' || key === 'j') { - return { action: 'move', newIndex: (currentIndex + 1) % totalItems }; - } - // Enter - if (key === '\r' || key === '\n') { - return { action: 'confirm', selectedIndex: currentIndex }; - } - // Ctrl+C - exit process - if (key === '\x03') { - return { action: 'exit' }; - } - // Escape - cancel - if (key === '\x1B') { - return { action: 'cancel', cancelIndex: hasCancelOption ? optionCount : -1 }; - } - return { action: 'none' }; -} - -/** - * Print the menu header (message + hint). - */ -function printHeader(message: string): void { - console.log(); - console.log(chalk.cyan(message)); - console.log(chalk.gray(' (↑↓ to move, Enter to select)')); - console.log(); -} - -/** - * Set up raw mode on stdin and return cleanup function. - */ -function setupRawMode(): { cleanup: (listener: (data: Buffer) => void) => void; wasRaw: boolean } { - const wasRaw = process.stdin.isRaw; - process.stdin.setRawMode(true); - process.stdin.resume(); - - return { - wasRaw, - cleanup(listener: (data: Buffer) => void): void { - process.stdin.removeListener('data', listener); - process.stdin.setRawMode(wasRaw ?? false); - process.stdin.pause(); - }, - }; -} - -/** - * Redraw the menu using relative cursor movement. - * Auto-wrap is disabled during menu interaction, so - * 1 logical line = 1 physical line, making line-count movement accurate. - */ -function redrawMenu( - options: SelectOptionItem[], - selectedIndex: number, - hasCancelOption: boolean, - totalLines: number -): void { - process.stdout.write(`\x1B[${totalLines}A`); // Move up to menu start - process.stdout.write('\x1B[J'); // Clear from cursor to end - const newLines = renderMenu(options, selectedIndex, hasCancelOption); - process.stdout.write(newLines.join('\n') + '\n'); -} - -/** - * Interactive cursor-based menu selection. - * Uses raw mode to capture arrow key input for navigation. - */ -function interactiveSelect( - message: string, - options: SelectOptionItem[], - initialIndex: number, - hasCancelOption: boolean -): Promise { - return new Promise((resolve) => { - const totalItems = hasCancelOption ? options.length + 1 : options.length; - let selectedIndex = initialIndex; - - printHeader(message); - - // Disable auto-wrap so 1 logical line = 1 physical line - process.stdout.write('\x1B[?7l'); - - const totalLines = countRenderedLines(options, hasCancelOption); - const lines = renderMenu(options, selectedIndex, hasCancelOption); - process.stdout.write(lines.join('\n') + '\n'); - - if (!process.stdin.isTTY) { - process.stdout.write('\x1B[?7h'); // Re-enable auto-wrap - resolve(initialIndex); - return; - } - - const rawMode = setupRawMode(); - - const cleanup = (listener: (data: Buffer) => void): void => { - rawMode.cleanup(listener); - process.stdout.write('\x1B[?7h'); // Re-enable auto-wrap - }; - - const onKeypress = (data: Buffer): void => { - const result = handleKeyInput( - data.toString(), - selectedIndex, - totalItems, - hasCancelOption, - options.length - ); - - switch (result.action) { - case 'move': - selectedIndex = result.newIndex; - redrawMenu(options, selectedIndex, hasCancelOption, totalLines); - break; - case 'confirm': - cleanup(onKeypress); - resolve(result.selectedIndex); - break; - case 'cancel': - cleanup(onKeypress); - resolve(result.cancelIndex); - break; - case 'exit': - cleanup(onKeypress); - process.exit(130); - break; - case 'none': - break; - } - }; - - process.stdin.on('data', onKeypress); - }); -} - -/** - * Prompt user to select from a list of options using cursor navigation. - * @returns Selected option or null if cancelled - */ -export async function selectOption( - message: string, - options: SelectOptionItem[] -): Promise { - if (options.length === 0) return null; - - const selectedIndex = await interactiveSelect(message, options, 0, true); - - // Cancel selected (last item or escape) - if (selectedIndex === options.length || selectedIndex === -1) { - return null; - } - - const selected = options[selectedIndex]; - if (selected) { - console.log(chalk.green(` ✓ ${selected.label}`)); - return selected.value; - } - - return null; -} - -/** - * Prompt user for simple text input - * @returns User input or null if cancelled - */ -export async function promptInput(message: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve) => { - rl.question(chalk.green(message + ': '), (answer) => { - rl.close(); - - const trimmed = answer.trim(); - if (!trimmed) { - resolve(null); - return; - } - - resolve(trimmed); - }); - }); -} - -/** - * Read multiline input from a readable stream. - * An empty line finishes input. If the first line is empty, returns null. - * Exported for testing. - */ -export function readMultilineFromStream(input: NodeJS.ReadableStream): Promise { - const lines: string[] = []; - const rl = readline.createInterface({ input }); - - return new Promise((resolve) => { - let resolved = false; - - rl.on('line', (line) => { - if (line === '' && lines.length > 0) { - resolved = true; - rl.close(); - const result = lines.join('\n').trim(); - resolve(result || null); - return; - } - - if (line === '' && lines.length === 0) { - resolved = true; - rl.close(); - resolve(null); - return; - } - - lines.push(line); - }); - - rl.on('close', () => { - if (!resolved) { - resolve(lines.length > 0 ? lines.join('\n').trim() : null); - } - }); - }); -} - -/** - * Prompt user to select from a list of options with a default value. - * Uses cursor navigation. Enter immediately selects the default. - * @returns Selected option value, or null if cancelled (ESC pressed) - */ -export async function selectOptionWithDefault( - message: string, - options: { label: string; value: T }[], - defaultValue: T -): Promise { - if (options.length === 0) return defaultValue; - - // Find default index - const defaultIndex = options.findIndex((opt) => opt.value === defaultValue); - const initialIndex = defaultIndex >= 0 ? defaultIndex : 0; - - // Mark default in label - const decoratedOptions: SelectOptionItem[] = options.map((opt) => ({ - ...opt, - label: opt.value === defaultValue ? `${opt.label} ${chalk.green('(default)')}` : opt.label, - })); - - const selectedIndex = await interactiveSelect(message, decoratedOptions, initialIndex, true); - - // Cancel selected (last item) or Escape pressed - if (selectedIndex === options.length || selectedIndex === -1) { - return null; - } - - const selected = options[selectedIndex]; - if (selected) { - console.log(chalk.green(` ✓ ${selected.label}`)); - return selected.value; - } - - return defaultValue; -} - -/** - * Prompt user for yes/no confirmation - * @returns true for yes, false for no - */ -export async function confirm(message: string, defaultYes = true): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - const hint = defaultYes ? '[Y/n]' : '[y/N]'; - - return new Promise((resolve) => { - rl.question(chalk.green(`${message} ${hint}: `), (answer) => { - rl.close(); - - const trimmed = answer.trim().toLowerCase(); - - if (!trimmed) { - resolve(defaultYes); - return; - } - - resolve(trimmed === 'y' || trimmed === 'yes'); - }); - }); -} +export { + promptInput, + readMultilineFromStream, + confirm, +} from './confirm.js'; diff --git a/src/prompt/select.ts b/src/prompt/select.ts new file mode 100644 index 0000000..b1d4e98 --- /dev/null +++ b/src/prompt/select.ts @@ -0,0 +1,280 @@ +/** + * Interactive cursor-based selection menus. + * + * Provides arrow-key navigation for option selection in the terminal. + */ + +import chalk from 'chalk'; +import { truncateText } from '../utils/text.js'; + +/** Option type for selectOption */ +export interface SelectOptionItem { + label: string; + value: T; + description?: string; + details?: string[]; +} + +/** + * Render the menu options to the terminal. + * Exported for testing. + */ +export function renderMenu( + options: SelectOptionItem[], + selectedIndex: number, + hasCancelOption: boolean, +): string[] { + const maxWidth = process.stdout.columns || 80; + const labelPrefix = 4; + const descPrefix = 5; + const detailPrefix = 9; + + const lines: string[] = []; + + for (let i = 0; i < options.length; i++) { + const opt = options[i]!; + const isSelected = i === selectedIndex; + const cursor = isSelected ? chalk.cyan('❯') : ' '; + const truncatedLabel = truncateText(opt.label, maxWidth - labelPrefix); + const label = isSelected ? chalk.cyan.bold(truncatedLabel) : truncatedLabel; + lines.push(` ${cursor} ${label}`); + + if (opt.description) { + const truncatedDesc = truncateText(opt.description, maxWidth - descPrefix); + lines.push(chalk.gray(` ${truncatedDesc}`)); + } + if (opt.details && opt.details.length > 0) { + for (const detail of opt.details) { + const truncatedDetail = truncateText(detail, maxWidth - detailPrefix); + lines.push(chalk.dim(` • ${truncatedDetail}`)); + } + } + } + + if (hasCancelOption) { + const isCancelSelected = selectedIndex === options.length; + const cursor = isCancelSelected ? chalk.cyan('❯') : ' '; + const label = isCancelSelected ? chalk.cyan.bold('Cancel') : chalk.gray('Cancel'); + lines.push(` ${cursor} ${label}`); + } + + return lines; +} + +/** + * Count total rendered lines for a set of options. + * Exported for testing. + */ +export function countRenderedLines( + options: SelectOptionItem[], + hasCancelOption: boolean, +): number { + let count = 0; + for (const opt of options) { + count++; + if (opt.description) count++; + if (opt.details) count += opt.details.length; + } + if (hasCancelOption) count++; + return count; +} + +/** Result of handling a key input */ +export type KeyInputResult = + | { action: 'move'; newIndex: number } + | { action: 'confirm'; selectedIndex: number } + | { action: 'cancel'; cancelIndex: number } + | { action: 'exit' } + | { action: 'none' }; + +/** + * Pure function for key input state transitions. + * Exported for testing. + */ +export function handleKeyInput( + key: string, + currentIndex: number, + totalItems: number, + hasCancelOption: boolean, + optionCount: number, +): KeyInputResult { + if (key === '\x1B[A' || key === 'k') { + return { action: 'move', newIndex: (currentIndex - 1 + totalItems) % totalItems }; + } + if (key === '\x1B[B' || key === 'j') { + return { action: 'move', newIndex: (currentIndex + 1) % totalItems }; + } + if (key === '\r' || key === '\n') { + return { action: 'confirm', selectedIndex: currentIndex }; + } + if (key === '\x03') { + return { action: 'exit' }; + } + if (key === '\x1B') { + return { action: 'cancel', cancelIndex: hasCancelOption ? optionCount : -1 }; + } + return { action: 'none' }; +} + +/** Print the menu header (message + hint). */ +function printHeader(message: string): void { + console.log(); + console.log(chalk.cyan(message)); + console.log(chalk.gray(' (↑↓ to move, Enter to select)')); + console.log(); +} + +/** Set up raw mode on stdin and return cleanup function. */ +function setupRawMode(): { cleanup: (listener: (data: Buffer) => void) => void; wasRaw: boolean } { + const wasRaw = process.stdin.isRaw; + process.stdin.setRawMode(true); + process.stdin.resume(); + + return { + wasRaw, + cleanup(listener: (data: Buffer) => void): void { + process.stdin.removeListener('data', listener); + process.stdin.setRawMode(wasRaw ?? false); + process.stdin.pause(); + }, + }; +} + +/** Redraw the menu using relative cursor movement. */ +function redrawMenu( + options: SelectOptionItem[], + selectedIndex: number, + hasCancelOption: boolean, + totalLines: number, +): void { + process.stdout.write(`\x1B[${totalLines}A`); + process.stdout.write('\x1B[J'); + const newLines = renderMenu(options, selectedIndex, hasCancelOption); + process.stdout.write(newLines.join('\n') + '\n'); +} + +/** Interactive cursor-based menu selection. */ +function interactiveSelect( + message: string, + options: SelectOptionItem[], + initialIndex: number, + hasCancelOption: boolean, +): Promise { + return new Promise((resolve) => { + const totalItems = hasCancelOption ? options.length + 1 : options.length; + let selectedIndex = initialIndex; + + printHeader(message); + + process.stdout.write('\x1B[?7l'); + + const totalLines = countRenderedLines(options, hasCancelOption); + const lines = renderMenu(options, selectedIndex, hasCancelOption); + process.stdout.write(lines.join('\n') + '\n'); + + if (!process.stdin.isTTY) { + process.stdout.write('\x1B[?7h'); + resolve(initialIndex); + return; + } + + const rawMode = setupRawMode(); + + const cleanup = (listener: (data: Buffer) => void): void => { + rawMode.cleanup(listener); + process.stdout.write('\x1B[?7h'); + }; + + const onKeypress = (data: Buffer): void => { + const result = handleKeyInput( + data.toString(), + selectedIndex, + totalItems, + hasCancelOption, + options.length, + ); + + switch (result.action) { + case 'move': + selectedIndex = result.newIndex; + redrawMenu(options, selectedIndex, hasCancelOption, totalLines); + break; + case 'confirm': + cleanup(onKeypress); + resolve(result.selectedIndex); + break; + case 'cancel': + cleanup(onKeypress); + resolve(result.cancelIndex); + break; + case 'exit': + cleanup(onKeypress); + process.exit(130); + break; + case 'none': + break; + } + }; + + process.stdin.on('data', onKeypress); + }); +} + +/** + * Prompt user to select from a list of options using cursor navigation. + * @returns Selected option or null if cancelled + */ +export async function selectOption( + message: string, + options: SelectOptionItem[], +): Promise { + if (options.length === 0) return null; + + const selectedIndex = await interactiveSelect(message, options, 0, true); + + if (selectedIndex === options.length || selectedIndex === -1) { + return null; + } + + const selected = options[selectedIndex]; + if (selected) { + console.log(chalk.green(` ✓ ${selected.label}`)); + return selected.value; + } + + return null; +} + +/** + * Prompt user to select from a list of options with a default value. + * @returns Selected option value, or null if cancelled (ESC pressed) + */ +export async function selectOptionWithDefault( + message: string, + options: { label: string; value: T }[], + defaultValue: T, +): Promise { + if (options.length === 0) return defaultValue; + + const defaultIndex = options.findIndex((opt) => opt.value === defaultValue); + const initialIndex = defaultIndex >= 0 ? defaultIndex : 0; + + const decoratedOptions: SelectOptionItem[] = options.map((opt) => ({ + ...opt, + label: opt.value === defaultValue ? `${opt.label} ${chalk.green('(default)')}` : opt.label, + })); + + const selectedIndex = await interactiveSelect(message, decoratedOptions, initialIndex, true); + + if (selectedIndex === options.length || selectedIndex === -1) { + return null; + } + + const selected = options[selectedIndex]; + if (selected) { + console.log(chalk.green(` ✓ ${selected.label}`)); + return selected.value; + } + + return defaultValue; +} diff --git a/src/utils/LogManager.ts b/src/utils/LogManager.ts new file mode 100644 index 0000000..4b9dbeb --- /dev/null +++ b/src/utils/LogManager.ts @@ -0,0 +1,155 @@ +/** + * Log level management and formatted console output. + * + * LogManager is a singleton that encapsulates the current log level state. + * Module-level functions are provided for backward compatibility. + */ + +import chalk from 'chalk'; + +/** Log levels */ +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +/** Log level priorities */ +const LOG_PRIORITIES: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, +}; + +/** + * Manages console log output level and provides formatted logging. + * Singleton — use LogManager.getInstance(). + */ +export class LogManager { + private static instance: LogManager | null = null; + private currentLogLevel: LogLevel = 'info'; + + private constructor() {} + + static getInstance(): LogManager { + if (!LogManager.instance) { + LogManager.instance = new LogManager(); + } + return LogManager.instance; + } + + /** Reset singleton for testing */ + static resetInstance(): void { + LogManager.instance = null; + } + + /** Set log level */ + setLogLevel(level: LogLevel): void { + this.currentLogLevel = level; + } + + /** Check if a log level should be shown */ + shouldLog(level: LogLevel): boolean { + return LOG_PRIORITIES[level] >= LOG_PRIORITIES[this.currentLogLevel]; + } + + /** Log a debug message */ + debug(message: string): void { + if (this.shouldLog('debug')) { + console.log(chalk.gray(`[DEBUG] ${message}`)); + } + } + + /** Log an info message */ + info(message: string): void { + if (this.shouldLog('info')) { + console.log(chalk.blue(`[INFO] ${message}`)); + } + } + + /** Log a warning message */ + warn(message: string): void { + if (this.shouldLog('warn')) { + console.log(chalk.yellow(`[WARN] ${message}`)); + } + } + + /** Log an error message */ + error(message: string): void { + if (this.shouldLog('error')) { + console.log(chalk.red(`[ERROR] ${message}`)); + } + } + + /** Log a success message */ + success(message: string): void { + console.log(chalk.green(message)); + } +} + +// ---- Backward-compatible module-level functions ---- + +export function setLogLevel(level: LogLevel): void { + LogManager.getInstance().setLogLevel(level); +} + +export function blankLine(): void { + console.log(); +} + +export function debug(message: string): void { + LogManager.getInstance().debug(message); +} + +export function info(message: string): void { + LogManager.getInstance().info(message); +} + +export function warn(message: string): void { + LogManager.getInstance().warn(message); +} + +export function error(message: string): void { + LogManager.getInstance().error(message); +} + +export function success(message: string): void { + LogManager.getInstance().success(message); +} + +export function header(title: string): void { + console.log(); + console.log(chalk.bold.cyan(`=== ${title} ===`)); + console.log(); +} + +export function section(title: string): void { + console.log(chalk.bold(`\n${title}`)); +} + +export function status(label: string, value: string, color?: 'green' | 'yellow' | 'red'): void { + const colorFn = color ? chalk[color] : chalk.white; + console.log(`${chalk.gray(label)}: ${colorFn(value)}`); +} + +export function progressBar(current: number, total: number, width = 30): string { + const percentage = Math.floor((current / total) * 100); + const filled = Math.floor((current / total) * width); + const empty = width - filled; + const bar = chalk.green('█'.repeat(filled)) + chalk.gray('░'.repeat(empty)); + return `[${bar}] ${percentage}%`; +} + +export function list(items: string[], bullet = '•'): void { + for (const item of items) { + console.log(chalk.gray(bullet) + ' ' + item); + } +} + +export function divider(char = '─', length = 40): void { + console.log(chalk.gray(char.repeat(length))); +} + +export function truncate(text: string, maxLength: number): string { + if (text.length <= maxLength) { + return text; + } + return text.slice(0, maxLength - 3) + '...'; +} diff --git a/src/utils/Spinner.ts b/src/utils/Spinner.ts new file mode 100644 index 0000000..b257f1d --- /dev/null +++ b/src/utils/Spinner.ts @@ -0,0 +1,43 @@ +/** + * Terminal spinner for async operations. + * + * Displays an animated spinner with a message while background work is in progress. + */ + +import chalk from 'chalk'; + +/** Spinner for async operations */ +export class Spinner { + private intervalId?: ReturnType; + private frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + private currentFrame = 0; + private message: string; + + constructor(message: string) { + this.message = message; + } + + start(): void { + this.intervalId = setInterval(() => { + process.stdout.write( + `\r${chalk.cyan(this.frames[this.currentFrame])} ${this.message}` + ); + this.currentFrame = (this.currentFrame + 1) % this.frames.length; + }, 80); + } + + stop(finalMessage?: string): void { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = undefined; + } + process.stdout.write('\r' + ' '.repeat(this.message.length + 10) + '\r'); + if (finalMessage) { + console.log(finalMessage); + } + } + + update(message: string): void { + this.message = message; + } +} diff --git a/src/utils/StreamDisplay.ts b/src/utils/StreamDisplay.ts new file mode 100644 index 0000000..02819bd --- /dev/null +++ b/src/utils/StreamDisplay.ts @@ -0,0 +1,283 @@ +/** + * Stream display manager for real-time Claude/Codex output. + * + * Handles text, thinking, tool use/result events and renders them + * to the terminal with appropriate formatting and spinners. + */ + +import chalk from 'chalk'; +import type { StreamEvent, StreamCallback } from '../claude/types.js'; +import { truncate } from './LogManager.js'; + +/** Stream display manager for real-time Claude output */ +export class StreamDisplay { + private lastToolUse: string | null = null; + private currentToolInputPreview: string | null = null; + private toolOutputBuffer = ''; + private toolOutputPrinted = false; + private textBuffer = ''; + private thinkingBuffer = ''; + private isFirstText = true; + private isFirstThinking = true; + private toolSpinner: { + intervalId: ReturnType; + toolName: string; + message: string; + } | null = null; + private spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + private spinnerFrame = 0; + + constructor( + private agentName = 'Claude', + private quiet = false, + ) {} + + showInit(model: string): void { + if (this.quiet) return; + console.log(chalk.gray(`[${this.agentName}] Model: ${model}`)); + } + + private startToolSpinner(tool: string, inputPreview: string): void { + this.stopToolSpinner(); + + const message = `${chalk.yellow(tool)} ${chalk.gray(inputPreview)}`; + this.toolSpinner = { + intervalId: setInterval(() => { + const frame = this.spinnerFrames[this.spinnerFrame]; + this.spinnerFrame = (this.spinnerFrame + 1) % this.spinnerFrames.length; + process.stdout.write(`\r ${chalk.cyan(frame)} ${message}`); + }, 80), + toolName: tool, + message, + }; + } + + private stopToolSpinner(): void { + if (this.toolSpinner) { + clearInterval(this.toolSpinner.intervalId); + process.stdout.write('\r' + ' '.repeat(120) + '\r'); + this.toolSpinner = null; + this.spinnerFrame = 0; + } + } + + showToolUse(tool: string, input: Record): void { + if (this.quiet) return; + this.flushText(); + const inputPreview = this.formatToolInput(tool, input); + this.startToolSpinner(tool, inputPreview); + this.lastToolUse = tool; + this.currentToolInputPreview = inputPreview; + this.toolOutputBuffer = ''; + this.toolOutputPrinted = false; + } + + showToolOutput(output: string, tool?: string): void { + if (this.quiet) return; + if (!output) return; + this.stopToolSpinner(); + this.flushThinking(); + this.flushText(); + + if (tool && !this.lastToolUse) { + this.lastToolUse = tool; + } + + this.toolOutputBuffer += output; + const lines = this.toolOutputBuffer.split(/\r?\n/); + this.toolOutputBuffer = lines.pop() ?? ''; + + this.printToolOutputLines(lines, tool); + + if (this.lastToolUse && this.currentToolInputPreview) { + this.startToolSpinner(this.lastToolUse, this.currentToolInputPreview); + } + } + + showToolResult(content: string, isError: boolean): void { + this.stopToolSpinner(); + + if (this.quiet) { + if (isError) { + const toolName = this.lastToolUse || 'Tool'; + const errorContent = content || 'Unknown error'; + console.log(chalk.red(` ✗ ${toolName}:`), chalk.red(truncate(errorContent, 70))); + } + this.lastToolUse = null; + this.currentToolInputPreview = null; + this.toolOutputPrinted = false; + return; + } + + if (this.toolOutputBuffer) { + this.printToolOutputLines([this.toolOutputBuffer], this.lastToolUse ?? undefined); + this.toolOutputBuffer = ''; + } + + const toolName = this.lastToolUse || 'Tool'; + if (isError) { + const errorContent = content || 'Unknown error'; + console.log(chalk.red(` ✗ ${toolName}:`), chalk.red(truncate(errorContent, 70))); + } else if (content && content.length > 0) { + const preview = content.split('\n')[0] || content; + console.log(chalk.green(` ✓ ${toolName}`), chalk.gray(truncate(preview, 60))); + } else { + console.log(chalk.green(` ✓ ${toolName}`)); + } + this.lastToolUse = null; + this.currentToolInputPreview = null; + this.toolOutputPrinted = false; + } + + showThinking(thinking: string): void { + if (this.quiet) return; + this.stopToolSpinner(); + this.flushText(); + + if (this.isFirstThinking) { + console.log(); + console.log(chalk.magenta(`💭 [${this.agentName} thinking]:`)); + this.isFirstThinking = false; + } + process.stdout.write(chalk.gray.italic(thinking)); + this.thinkingBuffer += thinking; + } + + flushThinking(): void { + if (this.thinkingBuffer) { + if (!this.thinkingBuffer.endsWith('\n')) { + console.log(); + } + this.thinkingBuffer = ''; + this.isFirstThinking = true; + } + } + + showText(text: string): void { + if (this.quiet) return; + this.stopToolSpinner(); + this.flushThinking(); + + if (this.isFirstText) { + console.log(); + console.log(chalk.cyan(`[${this.agentName}]:`)); + this.isFirstText = false; + } + process.stdout.write(text); + this.textBuffer += text; + } + + flushText(): void { + if (this.textBuffer) { + if (!this.textBuffer.endsWith('\n')) { + console.log(); + } + this.textBuffer = ''; + this.isFirstText = true; + } + } + + flush(): void { + this.stopToolSpinner(); + this.flushThinking(); + this.flushText(); + } + + showResult(success: boolean, error?: string): void { + this.stopToolSpinner(); + this.flushThinking(); + this.flushText(); + console.log(); + if (success) { + console.log(chalk.green('✓ Complete')); + } else { + console.log(chalk.red('✗ Failed')); + if (error) { + console.log(chalk.red(` ${error}`)); + } + } + } + + reset(): void { + this.stopToolSpinner(); + this.lastToolUse = null; + this.currentToolInputPreview = null; + this.toolOutputBuffer = ''; + this.toolOutputPrinted = false; + this.textBuffer = ''; + this.thinkingBuffer = ''; + this.isFirstText = true; + this.isFirstThinking = true; + } + + createHandler(): StreamCallback { + return (event: StreamEvent): void => { + switch (event.type) { + case 'init': + this.showInit(event.data.model); + break; + case 'tool_use': + this.showToolUse(event.data.tool, event.data.input); + break; + case 'tool_result': + this.showToolResult(event.data.content, event.data.isError); + break; + case 'tool_output': + this.showToolOutput(event.data.output, event.data.tool); + break; + case 'text': + this.showText(event.data.text); + break; + case 'thinking': + this.showThinking(event.data.thinking); + break; + case 'result': + this.showResult(event.data.success, event.data.error); + break; + case 'error': + break; + } + }; + } + + private formatToolInput(tool: string, input: Record): string { + switch (tool) { + case 'Bash': + return truncate(String(input.command || ''), 60); + case 'Read': + return truncate(String(input.file_path || ''), 60); + case 'Write': + case 'Edit': + return truncate(String(input.file_path || ''), 60); + case 'Glob': + return truncate(String(input.pattern || ''), 60); + case 'Grep': + return truncate(String(input.pattern || ''), 60); + default: { + const keys = Object.keys(input); + if (keys.length === 0) return ''; + const firstKey = keys[0]; + if (firstKey) { + const value = input[firstKey]; + return truncate(String(value || ''), 50); + } + return ''; + } + } + } + + private ensureToolOutputHeader(tool?: string): void { + if (this.toolOutputPrinted) return; + const label = tool || this.lastToolUse || 'Tool'; + console.log(chalk.gray(` ${chalk.yellow(label)} output:`)); + this.toolOutputPrinted = true; + } + + private printToolOutputLines(lines: string[], tool?: string): void { + if (lines.length === 0) return; + this.ensureToolOutputHeader(tool); + for (const line of lines) { + console.log(chalk.gray(` │ ${line}`)); + } + } +} diff --git a/src/utils/session.ts b/src/utils/session.ts index ed743c9..5c97be0 100644 --- a/src/utils/session.ts +++ b/src/utils/session.ts @@ -5,93 +5,24 @@ import { existsSync, readFileSync, copyFileSync, appendFileSync } from 'node:fs'; import { join } from 'node:path'; import { getProjectLogsDir, getGlobalLogsDir, ensureDir, writeFileAtomic } from '../config/paths.js'; +import type { + SessionLog, + NdjsonRecord, + NdjsonWorkflowStart, + LatestLogPointer, +} from './types.js'; -/** Session log entry */ -export interface SessionLog { - task: string; - projectDir: string; - workflowName: string; - iterations: number; - startTime: string; - endTime?: string; - status: 'running' | 'completed' | 'aborted'; - history: Array<{ - step: string; - agent: string; - instruction: string; - status: string; - timestamp: string; - content: string; - error?: string; - /** Matched rule index (0-based) when rules-based detection was used */ - matchedRuleIndex?: number; - /** How the rule match was detected */ - matchedRuleMethod?: string; - }>; -} - -// --- NDJSON log types --- - -export interface NdjsonWorkflowStart { - type: 'workflow_start'; - task: string; - workflowName: string; - startTime: string; -} - -export interface NdjsonStepStart { - type: 'step_start'; - step: string; - agent: string; - iteration: number; - timestamp: string; - instruction?: string; -} - -export interface NdjsonStepComplete { - type: 'step_complete'; - step: string; - agent: string; - status: string; - content: string; - instruction: string; - matchedRuleIndex?: number; - matchedRuleMethod?: string; - error?: string; - timestamp: string; -} - -export interface NdjsonWorkflowComplete { - type: 'workflow_complete'; - iterations: number; - endTime: string; -} - -export interface NdjsonWorkflowAbort { - type: 'workflow_abort'; - iterations: number; - reason: string; - endTime: string; -} - -export type NdjsonRecord = - | NdjsonWorkflowStart - | NdjsonStepStart - | NdjsonStepComplete - | NdjsonWorkflowComplete - | NdjsonWorkflowAbort; - -/** Pointer metadata for latest/previous log files */ -export interface LatestLogPointer { - sessionId: string; - logFile: string; - task: string; - workflowName: string; - status: SessionLog['status']; - startTime: string; - updatedAt: string; - iterations: number; -} +// Re-export types for backward compatibility +export type { + SessionLog, + NdjsonWorkflowStart, + NdjsonStepStart, + NdjsonStepComplete, + NdjsonWorkflowComplete, + NdjsonWorkflowAbort, + NdjsonRecord, + LatestLogPointer, +} from './types.js'; /** * Manages session lifecycle: ID generation, NDJSON logging, diff --git a/src/utils/types.ts b/src/utils/types.ts new file mode 100644 index 0000000..97cba8c --- /dev/null +++ b/src/utils/types.ts @@ -0,0 +1,93 @@ +/** + * Type definitions for utils module. + * + * Contains session log types and NDJSON record types + * used by SessionManager and its consumers. + */ + +/** Session log entry */ +export interface SessionLog { + task: string; + projectDir: string; + workflowName: string; + iterations: number; + startTime: string; + endTime?: string; + status: 'running' | 'completed' | 'aborted'; + history: Array<{ + step: string; + agent: string; + instruction: string; + status: string; + timestamp: string; + content: string; + error?: string; + /** Matched rule index (0-based) when rules-based detection was used */ + matchedRuleIndex?: number; + /** How the rule match was detected */ + matchedRuleMethod?: string; + }>; +} + +// --- NDJSON log types --- + +export interface NdjsonWorkflowStart { + type: 'workflow_start'; + task: string; + workflowName: string; + startTime: string; +} + +export interface NdjsonStepStart { + type: 'step_start'; + step: string; + agent: string; + iteration: number; + timestamp: string; + instruction?: string; +} + +export interface NdjsonStepComplete { + type: 'step_complete'; + step: string; + agent: string; + status: string; + content: string; + instruction: string; + matchedRuleIndex?: number; + matchedRuleMethod?: string; + error?: string; + timestamp: string; +} + +export interface NdjsonWorkflowComplete { + type: 'workflow_complete'; + iterations: number; + endTime: string; +} + +export interface NdjsonWorkflowAbort { + type: 'workflow_abort'; + iterations: number; + reason: string; + endTime: string; +} + +export type NdjsonRecord = + | NdjsonWorkflowStart + | NdjsonStepStart + | NdjsonStepComplete + | NdjsonWorkflowComplete + | NdjsonWorkflowAbort; + +/** Pointer metadata for latest/previous log files */ +export interface LatestLogPointer { + sessionId: string; + logFile: string; + task: string; + workflowName: string; + status: SessionLog['status']; + startTime: string; + updatedAt: string; + iterations: number; +} diff --git a/src/utils/ui.ts b/src/utils/ui.ts index 7654e7b..9e0e2be 100644 --- a/src/utils/ui.ts +++ b/src/utils/ui.ts @@ -1,462 +1,31 @@ /** - * UI utilities for terminal output + * UI utilities for terminal output — re-export hub. + * + * All implementations have been split into dedicated files: + * - LogManager.ts: Log level management and formatted output + * - Spinner.ts: Animated terminal spinner + * - StreamDisplay.ts: Real-time stream display for Claude/Codex output */ -import chalk from 'chalk'; -import type { StreamEvent, StreamCallback } from '../claude/types.js'; +export { + LogManager, + type LogLevel, + setLogLevel, + blankLine, + debug, + info, + warn, + error, + success, + header, + section, + status, + progressBar, + list, + divider, + truncate, +} from './LogManager.js'; -/** Log levels */ -export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; +export { Spinner } from './Spinner.js'; -/** Log level priorities */ -const LOG_PRIORITIES: Record = { - debug: 0, - info: 1, - warn: 2, - error: 3, -}; - -/** - * Manages console log output level and provides formatted logging. - * Singleton — use LogManager.getInstance(). - */ -export class LogManager { - private static instance: LogManager | null = null; - private currentLogLevel: LogLevel = 'info'; - - private constructor() {} - - static getInstance(): LogManager { - if (!LogManager.instance) { - LogManager.instance = new LogManager(); - } - return LogManager.instance; - } - - /** Reset singleton for testing */ - static resetInstance(): void { - LogManager.instance = null; - } - - /** Set log level */ - setLogLevel(level: LogLevel): void { - this.currentLogLevel = level; - } - - /** Check if a log level should be shown */ - shouldLog(level: LogLevel): boolean { - return LOG_PRIORITIES[level] >= LOG_PRIORITIES[this.currentLogLevel]; - } - - /** Log a debug message */ - debug(message: string): void { - if (this.shouldLog('debug')) { - console.log(chalk.gray(`[DEBUG] ${message}`)); - } - } - - /** Log an info message */ - info(message: string): void { - if (this.shouldLog('info')) { - console.log(chalk.blue(`[INFO] ${message}`)); - } - } - - /** Log a warning message */ - warn(message: string): void { - if (this.shouldLog('warn')) { - console.log(chalk.yellow(`[WARN] ${message}`)); - } - } - - /** Log an error message */ - error(message: string): void { - if (this.shouldLog('error')) { - console.log(chalk.red(`[ERROR] ${message}`)); - } - } - - /** Log a success message */ - success(message: string): void { - console.log(chalk.green(message)); - } -} - -// ---- Backward-compatible module-level functions ---- - -export function setLogLevel(level: LogLevel): void { - LogManager.getInstance().setLogLevel(level); -} - -export function blankLine(): void { - console.log(); -} - -export function debug(message: string): void { - LogManager.getInstance().debug(message); -} - -export function info(message: string): void { - LogManager.getInstance().info(message); -} - -export function warn(message: string): void { - LogManager.getInstance().warn(message); -} - -export function error(message: string): void { - LogManager.getInstance().error(message); -} - -export function success(message: string): void { - LogManager.getInstance().success(message); -} - -export function header(title: string): void { - console.log(); - console.log(chalk.bold.cyan(`=== ${title} ===`)); - console.log(); -} - -export function section(title: string): void { - console.log(chalk.bold(`\n${title}`)); -} - -export function status(label: string, value: string, color?: 'green' | 'yellow' | 'red'): void { - const colorFn = color ? chalk[color] : chalk.white; - console.log(`${chalk.gray(label)}: ${colorFn(value)}`); -} - -/** Spinner for async operations */ -export class Spinner { - private intervalId?: ReturnType; - private frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - private currentFrame = 0; - private message: string; - - constructor(message: string) { - this.message = message; - } - - start(): void { - this.intervalId = setInterval(() => { - process.stdout.write( - `\r${chalk.cyan(this.frames[this.currentFrame])} ${this.message}` - ); - this.currentFrame = (this.currentFrame + 1) % this.frames.length; - }, 80); - } - - stop(finalMessage?: string): void { - if (this.intervalId) { - clearInterval(this.intervalId); - this.intervalId = undefined; - } - process.stdout.write('\r' + ' '.repeat(this.message.length + 10) + '\r'); - if (finalMessage) { - console.log(finalMessage); - } - } - - update(message: string): void { - this.message = message; - } -} - -export function progressBar(current: number, total: number, width = 30): string { - const percentage = Math.floor((current / total) * 100); - const filled = Math.floor((current / total) * width); - const empty = width - filled; - const bar = chalk.green('█'.repeat(filled)) + chalk.gray('░'.repeat(empty)); - return `[${bar}] ${percentage}%`; -} - -export function list(items: string[], bullet = '•'): void { - for (const item of items) { - console.log(chalk.gray(bullet) + ' ' + item); - } -} - -export function divider(char = '─', length = 40): void { - console.log(chalk.gray(char.repeat(length))); -} - -export function truncate(text: string, maxLength: number): string { - if (text.length <= maxLength) { - return text; - } - return text.slice(0, maxLength - 3) + '...'; -} - -/** Stream display manager for real-time Claude output */ -export class StreamDisplay { - private lastToolUse: string | null = null; - private currentToolInputPreview: string | null = null; - private toolOutputBuffer = ''; - private toolOutputPrinted = false; - private textBuffer = ''; - private thinkingBuffer = ''; - private isFirstText = true; - private isFirstThinking = true; - private toolSpinner: { - intervalId: ReturnType; - toolName: string; - message: string; - } | null = null; - private spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - private spinnerFrame = 0; - - constructor( - private agentName = 'Claude', - private quiet = false, - ) {} - - showInit(model: string): void { - if (this.quiet) return; - console.log(chalk.gray(`[${this.agentName}] Model: ${model}`)); - } - - private startToolSpinner(tool: string, inputPreview: string): void { - this.stopToolSpinner(); - - const message = `${chalk.yellow(tool)} ${chalk.gray(inputPreview)}`; - this.toolSpinner = { - intervalId: setInterval(() => { - const frame = this.spinnerFrames[this.spinnerFrame]; - this.spinnerFrame = (this.spinnerFrame + 1) % this.spinnerFrames.length; - process.stdout.write(`\r ${chalk.cyan(frame)} ${message}`); - }, 80), - toolName: tool, - message, - }; - } - - private stopToolSpinner(): void { - if (this.toolSpinner) { - clearInterval(this.toolSpinner.intervalId); - process.stdout.write('\r' + ' '.repeat(120) + '\r'); - this.toolSpinner = null; - this.spinnerFrame = 0; - } - } - - showToolUse(tool: string, input: Record): void { - if (this.quiet) return; - this.flushText(); - const inputPreview = this.formatToolInput(tool, input); - this.startToolSpinner(tool, inputPreview); - this.lastToolUse = tool; - this.currentToolInputPreview = inputPreview; - this.toolOutputBuffer = ''; - this.toolOutputPrinted = false; - } - - showToolOutput(output: string, tool?: string): void { - if (this.quiet) return; - if (!output) return; - this.stopToolSpinner(); - this.flushThinking(); - this.flushText(); - - if (tool && !this.lastToolUse) { - this.lastToolUse = tool; - } - - this.toolOutputBuffer += output; - const lines = this.toolOutputBuffer.split(/\r?\n/); - this.toolOutputBuffer = lines.pop() ?? ''; - - this.printToolOutputLines(lines, tool); - - if (this.lastToolUse && this.currentToolInputPreview) { - this.startToolSpinner(this.lastToolUse, this.currentToolInputPreview); - } - } - - showToolResult(content: string, isError: boolean): void { - this.stopToolSpinner(); - - if (this.quiet) { - if (isError) { - const toolName = this.lastToolUse || 'Tool'; - const errorContent = content || 'Unknown error'; - console.log(chalk.red(` ✗ ${toolName}:`), chalk.red(truncate(errorContent, 70))); - } - this.lastToolUse = null; - this.currentToolInputPreview = null; - this.toolOutputPrinted = false; - return; - } - - if (this.toolOutputBuffer) { - this.printToolOutputLines([this.toolOutputBuffer], this.lastToolUse ?? undefined); - this.toolOutputBuffer = ''; - } - - const toolName = this.lastToolUse || 'Tool'; - if (isError) { - const errorContent = content || 'Unknown error'; - console.log(chalk.red(` ✗ ${toolName}:`), chalk.red(truncate(errorContent, 70))); - } else if (content && content.length > 0) { - const preview = content.split('\n')[0] || content; - console.log(chalk.green(` ✓ ${toolName}`), chalk.gray(truncate(preview, 60))); - } else { - console.log(chalk.green(` ✓ ${toolName}`)); - } - this.lastToolUse = null; - this.currentToolInputPreview = null; - this.toolOutputPrinted = false; - } - - showThinking(thinking: string): void { - if (this.quiet) return; - this.stopToolSpinner(); - this.flushText(); - - if (this.isFirstThinking) { - console.log(); - console.log(chalk.magenta(`💭 [${this.agentName} thinking]:`)); - this.isFirstThinking = false; - } - process.stdout.write(chalk.gray.italic(thinking)); - this.thinkingBuffer += thinking; - } - - flushThinking(): void { - if (this.thinkingBuffer) { - if (!this.thinkingBuffer.endsWith('\n')) { - console.log(); - } - this.thinkingBuffer = ''; - this.isFirstThinking = true; - } - } - - showText(text: string): void { - if (this.quiet) return; - this.stopToolSpinner(); - this.flushThinking(); - - if (this.isFirstText) { - console.log(); - console.log(chalk.cyan(`[${this.agentName}]:`)); - this.isFirstText = false; - } - process.stdout.write(text); - this.textBuffer += text; - } - - flushText(): void { - if (this.textBuffer) { - if (!this.textBuffer.endsWith('\n')) { - console.log(); - } - this.textBuffer = ''; - this.isFirstText = true; - } - } - - flush(): void { - this.stopToolSpinner(); - this.flushThinking(); - this.flushText(); - } - - showResult(success: boolean, error?: string): void { - this.stopToolSpinner(); - this.flushThinking(); - this.flushText(); - console.log(); - if (success) { - console.log(chalk.green('✓ Complete')); - } else { - console.log(chalk.red('✗ Failed')); - if (error) { - console.log(chalk.red(` ${error}`)); - } - } - } - - reset(): void { - this.stopToolSpinner(); - this.lastToolUse = null; - this.currentToolInputPreview = null; - this.toolOutputBuffer = ''; - this.toolOutputPrinted = false; - this.textBuffer = ''; - this.thinkingBuffer = ''; - this.isFirstText = true; - this.isFirstThinking = true; - } - - createHandler(): StreamCallback { - return (event: StreamEvent): void => { - switch (event.type) { - case 'init': - this.showInit(event.data.model); - break; - case 'tool_use': - this.showToolUse(event.data.tool, event.data.input); - break; - case 'tool_result': - this.showToolResult(event.data.content, event.data.isError); - break; - case 'tool_output': - this.showToolOutput(event.data.output, event.data.tool); - break; - case 'text': - this.showText(event.data.text); - break; - case 'thinking': - this.showThinking(event.data.thinking); - break; - case 'result': - this.showResult(event.data.success, event.data.error); - break; - case 'error': - break; - } - }; - } - - private formatToolInput(tool: string, input: Record): string { - switch (tool) { - case 'Bash': - return truncate(String(input.command || ''), 60); - case 'Read': - return truncate(String(input.file_path || ''), 60); - case 'Write': - case 'Edit': - return truncate(String(input.file_path || ''), 60); - case 'Glob': - return truncate(String(input.pattern || ''), 60); - case 'Grep': - return truncate(String(input.pattern || ''), 60); - default: { - const keys = Object.keys(input); - if (keys.length === 0) return ''; - const firstKey = keys[0]; - if (firstKey) { - const value = input[firstKey]; - return truncate(String(value || ''), 50); - } - return ''; - } - } - } - - private ensureToolOutputHeader(tool?: string): void { - if (this.toolOutputPrinted) return; - const label = tool || this.lastToolUse || 'Tool'; - console.log(chalk.gray(` ${chalk.yellow(label)} output:`)); - this.toolOutputPrinted = true; - } - - private printToolOutputLines(lines: string[], tool?: string): void { - if (lines.length === 0) return; - this.ensureToolOutputHeader(tool); - for (const line of lines) { - console.log(chalk.gray(` │ ${line}`)); - } - } -} +export { StreamDisplay } from './StreamDisplay.js';