This commit is contained in:
nrslib 2026-02-02 11:07:10 +09:00
parent 710d108f53
commit a66eb24009
18 changed files with 2239 additions and 2147 deletions

View File

@ -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<string>;
outputOffsets: Map<string, number>;
textOffsets: Map<string, number>;
thinkingOffsets: Map<string, number>;
}
export function createStreamTrackingState(): StreamTrackingState {
return {
startedItems: new Set<string>(),
outputOffsets: new Map<string, number>(),
textOffsets: new Map<string, number>(),
thinkingOffsets: new Map<string, number>(),
};
}
// ---- Stream emission helpers ----
export function extractThreadId(value: unknown): string | undefined {
if (!value || typeof value !== 'object') return undefined;
const record = value as Record<string, unknown>;
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<string, unknown>,
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<string>,
): 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<string, unknown>;
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;
}
}

View File

@ -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<string, unknown>;
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<string, unknown>,
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<string>,
): 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<string, unknown>;
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<string>,
outputOffsets: Map<string, number>,
textOffsets: Map<string, number>,
thinkingOffsets: Map<string, number>,
): 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<string>,
outputOffsets: Map<string, number>,
textOffsets: Map<string, number>,
thinkingOffsets: Map<string, number>,
): 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<string, number>();
let success = true;
let failureMessage = '';
const startedItems = new Set<string>();
const outputOffsets = new Map<string, number>();
const textOffsets = new Map<string, number>();
const thinkingOffsets = new Map<string, number>();
const state = createStreamTrackingState();
for await (const event of events as AsyncGenerator<CodexEvent>) {
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,

View File

@ -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<ListAction | null> {
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<ListAction>(
`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<string | null> {
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<boolean> {
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

View File

@ -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<ListAction | null> {
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<ListAction>(
`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<string | null> {
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<boolean> {
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);
}
}

View File

@ -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<string, unknown> = {
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<string, unknown> = {};
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<string, unknown> = {
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<string, unknown> = {};
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);

View File

@ -3,6 +3,7 @@
*/
export {
GlobalConfigManager,
invalidateGlobalConfigCache,
loadGlobalConfig,
saveGlobalConfig,

View File

@ -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<string, string>[] | { 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<string, string>[]).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<string, WorkflowConfig> {
const workflows = new Map<string, WorkflowConfig>();
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<string, WorkflowConfig>,
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<string>();
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<string>, 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';

View File

@ -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<typeof WorkflowStepRawSchema>;
/**
* 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<string, string>[] | { 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<string, string>[]).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);
}

View File

@ -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<WorkflowDirEntry> {
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<string, WorkflowConfig> {
const workflows = new Map<string, WorkflowConfig>();
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<string>();
for (const { dir, disabled } of getWorkflowDirs(cwd)) {
for (const entry of iterateWorkflowDir(dir, disabled)) {
workflows.add(entry.name);
}
}
return Array.from(workflows).sort();
}

101
src/prompt/confirm.ts Normal file
View File

@ -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<string | null> {
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<string | null> {
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<boolean> {
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');
});
});
}

View File

@ -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<T extends string> {
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<T extends string>(
options: SelectOptionItem<T>[],
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<T extends string>(
options: SelectOptionItem<T>[],
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<T extends string>(
options: SelectOptionItem<T>[],
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<T extends string>(
message: string,
options: SelectOptionItem<T>[],
initialIndex: number,
hasCancelOption: boolean
): Promise<number> {
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<T extends string>(
message: string,
options: SelectOptionItem<T>[]
): Promise<T | null> {
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<string | null> {
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<string | null> {
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<T extends string>(
message: string,
options: { label: string; value: T }[],
defaultValue: T
): Promise<T | null> {
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<T>[] = 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<boolean> {
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';

280
src/prompt/select.ts Normal file
View File

@ -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<T extends string> {
label: string;
value: T;
description?: string;
details?: string[];
}
/**
* Render the menu options to the terminal.
* Exported for testing.
*/
export function renderMenu<T extends string>(
options: SelectOptionItem<T>[],
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<T extends string>(
options: SelectOptionItem<T>[],
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<T extends string>(
options: SelectOptionItem<T>[],
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<T extends string>(
message: string,
options: SelectOptionItem<T>[],
initialIndex: number,
hasCancelOption: boolean,
): Promise<number> {
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<T extends string>(
message: string,
options: SelectOptionItem<T>[],
): Promise<T | null> {
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<T extends string>(
message: string,
options: { label: string; value: T }[],
defaultValue: T,
): Promise<T | null> {
if (options.length === 0) return defaultValue;
const defaultIndex = options.findIndex((opt) => opt.value === defaultValue);
const initialIndex = defaultIndex >= 0 ? defaultIndex : 0;
const decoratedOptions: SelectOptionItem<T>[] = 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;
}

155
src/utils/LogManager.ts Normal file
View File

@ -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<LogLevel, number> = {
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) + '...';
}

43
src/utils/Spinner.ts Normal file
View File

@ -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<typeof setInterval>;
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;
}
}

283
src/utils/StreamDisplay.ts Normal file
View File

@ -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<typeof setInterval>;
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<string, unknown>): 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, unknown>): 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}`));
}
}
}

View File

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

93
src/utils/types.ts Normal file
View File

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

View File

@ -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<LogLevel, number> = {
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<typeof setInterval>;
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<typeof setInterval>;
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<string, unknown>): 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, unknown>): 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';