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 { Codex } from '@openai/codex-sdk';
|
||||||
import type { AgentResponse, Status } from '../models/types.js';
|
import type { AgentResponse } from '../models/types.js';
|
||||||
import type { StreamCallback } from '../claude/types.js';
|
|
||||||
import { createLogger } from '../utils/debug.js';
|
import { createLogger } from '../utils/debug.js';
|
||||||
import { getErrorMessage } from '../utils/error.js';
|
import { getErrorMessage } from '../utils/error.js';
|
||||||
import type { CodexCallOptions } from './types.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
|
// Re-export for backward compatibility
|
||||||
export type { CodexCallOptions } from './types.js';
|
export type { CodexCallOptions } from './types.js';
|
||||||
|
|
||||||
const log = createLogger('codex-sdk');
|
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.
|
* Client for Codex SDK agent interactions.
|
||||||
*
|
*
|
||||||
@ -34,298 +33,6 @@ type CodexItem = {
|
|||||||
* and response processing.
|
* and response processing.
|
||||||
*/
|
*/
|
||||||
export class CodexClient {
|
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 */
|
/** Call Codex with an agent prompt */
|
||||||
async call(
|
async call(
|
||||||
agentType: string,
|
agentType: string,
|
||||||
@ -340,7 +47,7 @@ export class CodexClient {
|
|||||||
const thread = options.sessionId
|
const thread = options.sessionId
|
||||||
? await codex.resumeThread(options.sessionId, threadOptions)
|
? await codex.resumeThread(options.sessionId, threadOptions)
|
||||||
: await codex.startThread(threadOptions);
|
: await codex.startThread(threadOptions);
|
||||||
let threadId = CodexClient.extractThreadId(thread) || options.sessionId;
|
let threadId = extractThreadId(thread) || options.sessionId;
|
||||||
|
|
||||||
const fullPrompt = options.systemPrompt
|
const fullPrompt = options.systemPrompt
|
||||||
? `${options.systemPrompt}\n\n${prompt}`
|
? `${options.systemPrompt}\n\n${prompt}`
|
||||||
@ -358,15 +65,12 @@ export class CodexClient {
|
|||||||
const contentOffsets = new Map<string, number>();
|
const contentOffsets = new Map<string, number>();
|
||||||
let success = true;
|
let success = true;
|
||||||
let failureMessage = '';
|
let failureMessage = '';
|
||||||
const startedItems = new Set<string>();
|
const state = createStreamTrackingState();
|
||||||
const outputOffsets = new Map<string, number>();
|
|
||||||
const textOffsets = new Map<string, number>();
|
|
||||||
const thinkingOffsets = new Map<string, number>();
|
|
||||||
|
|
||||||
for await (const event of events as AsyncGenerator<CodexEvent>) {
|
for await (const event of events as AsyncGenerator<CodexEvent>) {
|
||||||
if (event.type === 'thread.started') {
|
if (event.type === 'thread.started') {
|
||||||
threadId = typeof event.thread_id === 'string' ? event.thread_id : threadId;
|
threadId = typeof event.thread_id === 'string' ? event.thread_id : threadId;
|
||||||
CodexClient.emitInit(options.onStream, options.model, threadId);
|
emitInit(options.onStream, options.model, threadId);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -387,7 +91,7 @@ export class CodexClient {
|
|||||||
if (event.type === 'item.started') {
|
if (event.type === 'item.started') {
|
||||||
const item = event.item as CodexItem | undefined;
|
const item = event.item as CodexItem | undefined;
|
||||||
if (item) {
|
if (item) {
|
||||||
CodexClient.emitCodexItemStart(item, options.onStream, startedItems);
|
emitCodexItemStart(item, options.onStream, state.startedItems);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -409,7 +113,7 @@ export class CodexClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CodexClient.emitCodexItemUpdate(item, options.onStream, startedItems, outputOffsets, textOffsets, thinkingOffsets);
|
emitCodexItemUpdate(item, options.onStream, state);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -436,14 +140,7 @@ export class CodexClient {
|
|||||||
content += text;
|
content += text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CodexClient.emitCodexItemCompleted(
|
emitCodexItemCompleted(item, options.onStream, state);
|
||||||
item,
|
|
||||||
options.onStream,
|
|
||||||
startedItems,
|
|
||||||
outputOffsets,
|
|
||||||
textOffsets,
|
|
||||||
thinkingOffsets,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -451,7 +148,7 @@ export class CodexClient {
|
|||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
const message = failureMessage || 'Codex execution failed';
|
const message = failureMessage || 'Codex execution failed';
|
||||||
CodexClient.emitResult(options.onStream, false, message, threadId);
|
emitResult(options.onStream, false, message, threadId);
|
||||||
return {
|
return {
|
||||||
agent: agentType,
|
agent: agentType,
|
||||||
status: 'blocked',
|
status: 'blocked',
|
||||||
@ -462,7 +159,7 @@ export class CodexClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const trimmed = content.trim();
|
const trimmed = content.trim();
|
||||||
CodexClient.emitResult(options.onStream, true, trimmed, threadId);
|
emitResult(options.onStream, true, trimmed, threadId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
agent: agentType,
|
agent: agentType,
|
||||||
@ -473,7 +170,7 @@ export class CodexClient {
|
|||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
CodexClient.emitResult(options.onStream, false, message, threadId);
|
emitResult(options.onStream, false, message, threadId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
agent: agentType,
|
agent: agentType,
|
||||||
|
|||||||
@ -1,360 +1,42 @@
|
|||||||
/**
|
/**
|
||||||
* List tasks command
|
* List tasks command — main entry point.
|
||||||
*
|
*
|
||||||
* Interactive UI for reviewing branch-based task results:
|
* Interactive UI for reviewing branch-based task results.
|
||||||
* try merge, merge & cleanup, or delete actions.
|
* Individual actions (merge, delete, instruct, diff) are in taskActions.ts.
|
||||||
* Clones are ephemeral — only branches persist between sessions.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execFileSync, spawnSync } from 'node:child_process';
|
|
||||||
import chalk from 'chalk';
|
|
||||||
import {
|
|
||||||
createTempCloneForBranch,
|
|
||||||
removeClone,
|
|
||||||
removeCloneMeta,
|
|
||||||
cleanupOrphanedClone,
|
|
||||||
} from '../../task/clone.js';
|
|
||||||
import {
|
import {
|
||||||
detectDefaultBranch,
|
detectDefaultBranch,
|
||||||
listTaktBranches,
|
listTaktBranches,
|
||||||
buildListItems,
|
buildListItems,
|
||||||
type BranchListItem,
|
|
||||||
} from '../../task/branchList.js';
|
} from '../../task/branchList.js';
|
||||||
import { autoCommitAndPush } from '../../task/autoCommit.js';
|
import { selectOption, confirm } from '../../prompt/index.js';
|
||||||
import { selectOption, confirm, promptInput } from '../../prompt/index.js';
|
import { info } from '../../utils/ui.js';
|
||||||
import { info, success, error as logError, warn, header, blankLine } from '../../utils/ui.js';
|
|
||||||
import { createLogger } from '../../utils/debug.js';
|
import { createLogger } from '../../utils/debug.js';
|
||||||
import { getErrorMessage } from '../../utils/error.js';
|
import type { TaskExecutionOptions } from '../execution/taskExecution.js';
|
||||||
import { executeTask, type TaskExecutionOptions } from '../execution/taskExecution.js';
|
import {
|
||||||
import { listWorkflows } from '../../config/loaders/workflowLoader.js';
|
type ListAction,
|
||||||
import { getCurrentWorkflow } from '../../config/paths.js';
|
showFullDiff,
|
||||||
import { DEFAULT_WORKFLOW_NAME } from '../../constants.js';
|
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');
|
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.
|
* 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) {
|
while (branches.length > 0) {
|
||||||
const items = buildListItems(cwd, branches, defaultBranch);
|
const items = buildListItems(cwd, branches, defaultBranch);
|
||||||
|
|
||||||
// Build selection options
|
|
||||||
const menuOptions = items.map((item, idx) => {
|
const menuOptions = items.map((item, idx) => {
|
||||||
const filesSummary = `${item.filesChanged} file${item.filesChanged !== 1 ? 's' : ''} changed`;
|
const filesSummary = `${item.filesChanged} file${item.filesChanged !== 1 ? 's' : ''} changed`;
|
||||||
const description = item.originalInstruction
|
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
|
* Global configuration loader
|
||||||
*
|
*
|
||||||
* Manages ~/.takt/config.yaml and project-level debug settings.
|
* Manages ~/.takt/config.yaml and project-level debug settings.
|
||||||
|
* GlobalConfigManager encapsulates the config cache as a singleton.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
|
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
|
||||||
@ -23,23 +24,42 @@ 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) */
|
private constructor() {}
|
||||||
export function invalidateGlobalConfigCache(): void {
|
|
||||||
cachedConfig = null;
|
static getInstance(): GlobalConfigManager {
|
||||||
|
if (!GlobalConfigManager.instance) {
|
||||||
|
GlobalConfigManager.instance = new GlobalConfigManager();
|
||||||
|
}
|
||||||
|
return GlobalConfigManager.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load global configuration */
|
/** Reset singleton for testing */
|
||||||
export function loadGlobalConfig(): GlobalConfig {
|
static resetInstance(): void {
|
||||||
if (cachedConfig !== null) {
|
GlobalConfigManager.instance = null;
|
||||||
return cachedConfig;
|
}
|
||||||
|
|
||||||
|
/** 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();
|
const configPath = getGlobalConfigPath();
|
||||||
if (!existsSync(configPath)) {
|
if (!existsSync(configPath)) {
|
||||||
const defaultConfig = createDefaultGlobalConfig();
|
const defaultConfig = createDefaultGlobalConfig();
|
||||||
cachedConfig = defaultConfig;
|
this.cachedConfig = defaultConfig;
|
||||||
return defaultConfig;
|
return defaultConfig;
|
||||||
}
|
}
|
||||||
const content = readFileSync(configPath, 'utf-8');
|
const content = readFileSync(configPath, 'utf-8');
|
||||||
@ -67,12 +87,12 @@ export function loadGlobalConfig(): GlobalConfig {
|
|||||||
} : undefined,
|
} : undefined,
|
||||||
minimalOutput: parsed.minimal_output,
|
minimalOutput: parsed.minimal_output,
|
||||||
};
|
};
|
||||||
cachedConfig = config;
|
this.cachedConfig = config;
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save global configuration */
|
/** Save global configuration to disk and invalidate cache */
|
||||||
export function saveGlobalConfig(config: GlobalConfig): void {
|
save(config: GlobalConfig): void {
|
||||||
const configPath = getGlobalConfigPath();
|
const configPath = getGlobalConfigPath();
|
||||||
const raw: Record<string, unknown> = {
|
const raw: Record<string, unknown> = {
|
||||||
language: config.language,
|
language: config.language,
|
||||||
@ -115,10 +135,24 @@ export function saveGlobalConfig(config: GlobalConfig): void {
|
|||||||
raw.minimal_output = config.minimalOutput;
|
raw.minimal_output = config.minimalOutput;
|
||||||
}
|
}
|
||||||
writeFileSync(configPath, stringifyYaml(raw), 'utf-8');
|
writeFileSync(configPath, stringifyYaml(raw), 'utf-8');
|
||||||
invalidateGlobalConfigCache();
|
this.invalidateCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get list of disabled builtin names */
|
|
||||||
export function getDisabledBuiltins(): string[] {
|
export function getDisabledBuiltins(): string[] {
|
||||||
try {
|
try {
|
||||||
const config = loadGlobalConfig();
|
const config = loadGlobalConfig();
|
||||||
@ -128,7 +162,6 @@ export function getDisabledBuiltins(): string[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get current language setting */
|
|
||||||
export function getLanguage(): Language {
|
export function getLanguage(): Language {
|
||||||
try {
|
try {
|
||||||
const config = loadGlobalConfig();
|
const config = loadGlobalConfig();
|
||||||
@ -138,21 +171,18 @@ export function getLanguage(): Language {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Set language setting */
|
|
||||||
export function setLanguage(language: Language): void {
|
export function setLanguage(language: Language): void {
|
||||||
const config = loadGlobalConfig();
|
const config = loadGlobalConfig();
|
||||||
config.language = language;
|
config.language = language;
|
||||||
saveGlobalConfig(config);
|
saveGlobalConfig(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Set provider setting */
|
|
||||||
export function setProvider(provider: 'claude' | 'codex'): void {
|
export function setProvider(provider: 'claude' | 'codex'): void {
|
||||||
const config = loadGlobalConfig();
|
const config = loadGlobalConfig();
|
||||||
config.provider = provider;
|
config.provider = provider;
|
||||||
saveGlobalConfig(config);
|
saveGlobalConfig(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Add a trusted directory */
|
|
||||||
export function addTrustedDirectory(dir: string): void {
|
export function addTrustedDirectory(dir: string): void {
|
||||||
const config = loadGlobalConfig();
|
const config = loadGlobalConfig();
|
||||||
const resolvedDir = join(dir);
|
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 {
|
export function isDirectoryTrusted(dir: string): boolean {
|
||||||
const config = loadGlobalConfig();
|
const config = loadGlobalConfig();
|
||||||
const resolvedDir = join(dir);
|
const resolvedDir = join(dir);
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
GlobalConfigManager,
|
||||||
invalidateGlobalConfigCache,
|
invalidateGlobalConfigCache,
|
||||||
loadGlobalConfig,
|
loadGlobalConfig,
|
||||||
saveGlobalConfig,
|
saveGlobalConfig,
|
||||||
|
|||||||
@ -1,449 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* Workflow configuration loader
|
* Workflow configuration loader — re-export hub.
|
||||||
*
|
*
|
||||||
* Loads workflows with the following priority:
|
* Implementations have been split into:
|
||||||
* 1. Path-based input (absolute, relative, or home-dir) → load directly from file
|
* - workflowParser.ts: YAML parsing, step/rule normalization
|
||||||
* 2. Project-local workflows: .takt/workflows/{name}.yaml
|
* - workflowResolver.ts: 3-layer resolution (builtin → user → project-local)
|
||||||
* 3. User workflows: ~/.takt/workflows/{name}.yaml
|
|
||||||
* 4. Builtin workflows: resources/global/{lang}/workflows/{name}.yaml
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
// Parser exports
|
||||||
import { join, dirname, basename, resolve, isAbsolute } from 'node:path';
|
export { normalizeWorkflowConfig, loadWorkflowFromFile } from './workflowParser.js';
|
||||||
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';
|
|
||||||
|
|
||||||
/** Get builtin workflow by name */
|
// Resolver exports (public API)
|
||||||
export function getBuiltinWorkflow(name: string): WorkflowConfig | null {
|
export {
|
||||||
const lang = getLanguage();
|
getBuiltinWorkflow,
|
||||||
const disabled = getDisabledBuiltins();
|
loadWorkflow,
|
||||||
if (disabled.includes(name)) return null;
|
isWorkflowPath,
|
||||||
|
loadWorkflowByIdentifier,
|
||||||
const builtinDir = getBuiltinWorkflowsDir(lang);
|
loadAllWorkflows,
|
||||||
const yamlPath = join(builtinDir, `${name}.yaml`);
|
listWorkflows,
|
||||||
if (existsSync(yamlPath)) {
|
} from './workflowResolver.js';
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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.
|
* Implementations have been split into:
|
||||||
* Users navigate with ↑/↓ keys and confirm with Enter.
|
* - select.ts: Cursor-based menu selection (arrow key navigation)
|
||||||
|
* - confirm.ts: Yes/no confirmation and text input prompts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as readline from 'node:readline';
|
export {
|
||||||
import chalk from 'chalk';
|
type SelectOptionItem,
|
||||||
import { truncateText } from '../utils/text.js';
|
renderMenu,
|
||||||
|
countRenderedLines,
|
||||||
|
type KeyInputResult,
|
||||||
|
handleKeyInput,
|
||||||
|
selectOption,
|
||||||
|
selectOptionWithDefault,
|
||||||
|
} from './select.js';
|
||||||
|
|
||||||
/** Option type for selectOption */
|
export {
|
||||||
export interface SelectOptionItem<T extends string> {
|
promptInput,
|
||||||
label: string;
|
readMultilineFromStream,
|
||||||
value: T;
|
confirm,
|
||||||
description?: string;
|
} from './confirm.js';
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
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 { existsSync, readFileSync, copyFileSync, appendFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { getProjectLogsDir, getGlobalLogsDir, ensureDir, writeFileAtomic } from '../config/paths.js';
|
import { getProjectLogsDir, getGlobalLogsDir, ensureDir, writeFileAtomic } from '../config/paths.js';
|
||||||
|
import type {
|
||||||
|
SessionLog,
|
||||||
|
NdjsonRecord,
|
||||||
|
NdjsonWorkflowStart,
|
||||||
|
LatestLogPointer,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
/** Session log entry */
|
// Re-export types for backward compatibility
|
||||||
export interface SessionLog {
|
export type {
|
||||||
task: string;
|
SessionLog,
|
||||||
projectDir: string;
|
NdjsonWorkflowStart,
|
||||||
workflowName: string;
|
NdjsonStepStart,
|
||||||
iterations: number;
|
NdjsonStepComplete,
|
||||||
startTime: string;
|
NdjsonWorkflowComplete,
|
||||||
endTime?: string;
|
NdjsonWorkflowAbort,
|
||||||
status: 'running' | 'completed' | 'aborted';
|
NdjsonRecord,
|
||||||
history: Array<{
|
LatestLogPointer,
|
||||||
step: string;
|
} from './types.js';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages session lifecycle: ID generation, NDJSON logging,
|
* 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';
|
export {
|
||||||
import type { StreamEvent, StreamCallback } from '../claude/types.js';
|
LogManager,
|
||||||
|
type LogLevel,
|
||||||
|
setLogLevel,
|
||||||
|
blankLine,
|
||||||
|
debug,
|
||||||
|
info,
|
||||||
|
warn,
|
||||||
|
error,
|
||||||
|
success,
|
||||||
|
header,
|
||||||
|
section,
|
||||||
|
status,
|
||||||
|
progressBar,
|
||||||
|
list,
|
||||||
|
divider,
|
||||||
|
truncate,
|
||||||
|
} from './LogManager.js';
|
||||||
|
|
||||||
/** Log levels */
|
export { Spinner } from './Spinner.js';
|
||||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
||||||
|
|
||||||
/** Log level priorities */
|
export { StreamDisplay } from './StreamDisplay.js';
|
||||||
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}`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user