module
This commit is contained in:
parent
710d108f53
commit
a66eb24009
320
src/codex/CodexStreamHandler.ts
Normal file
320
src/codex/CodexStreamHandler.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
331
src/commands/management/taskActions.ts
Normal file
331
src/commands/management/taskActions.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
export {
|
||||
GlobalConfigManager,
|
||||
invalidateGlobalConfigCache,
|
||||
loadGlobalConfig,
|
||||
saveGlobalConfig,
|
||||
|
||||
@ -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';
|
||||
|
||||
197
src/config/loaders/workflowParser.ts
Normal file
197
src/config/loaders/workflowParser.ts
Normal 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);
|
||||
}
|
||||
189
src/config/loaders/workflowResolver.ts
Normal file
189
src/config/loaders/workflowResolver.ts
Normal 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
101
src/prompt/confirm.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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
280
src/prompt/select.ts
Normal 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
155
src/utils/LogManager.ts
Normal 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
43
src/utils/Spinner.ts
Normal 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
283
src/utils/StreamDisplay.ts
Normal 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}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
93
src/utils/types.ts
Normal 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;
|
||||
}
|
||||
483
src/utils/ui.ts
483
src/utils/ui.ts
@ -1,462 +1,31 @@
|
||||
/**
|
||||
* UI utilities for terminal output
|
||||
* UI utilities for terminal output — re-export hub.
|
||||
*
|
||||
* All implementations have been split into dedicated files:
|
||||
* - LogManager.ts: Log level management and formatted output
|
||||
* - Spinner.ts: Animated terminal spinner
|
||||
* - StreamDisplay.ts: Real-time stream display for Claude/Codex output
|
||||
*/
|
||||
|
||||
import chalk from 'chalk';
|
||||
import type { StreamEvent, StreamCallback } from '../claude/types.js';
|
||||
export {
|
||||
LogManager,
|
||||
type LogLevel,
|
||||
setLogLevel,
|
||||
blankLine,
|
||||
debug,
|
||||
info,
|
||||
warn,
|
||||
error,
|
||||
success,
|
||||
header,
|
||||
section,
|
||||
status,
|
||||
progressBar,
|
||||
list,
|
||||
divider,
|
||||
truncate,
|
||||
} from './LogManager.js';
|
||||
|
||||
/** Log levels */
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
export { Spinner } from './Spinner.js';
|
||||
|
||||
/** Log level priorities */
|
||||
const LOG_PRIORITIES: Record<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';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user