takt/src/utils/ui.ts
nrslib d6ac71f0e6 | 作成 | src/task/git.ts | stageAndCommit() 共通関数。git commit ロジックのDRY化 |
| 作成 | `src/workflow/instruction-context.ts` | `instruction-builder.ts` からコンテキスト組立ロジック抽出 |
| 作成 | `src/workflow/status-rules.ts` | `instruction-builder.ts` からステータスルールロジック抽出 |
| 変更 | 35ファイル | `getErrorMessage()` 統一、`projectCwd` required 化、`process.cwd()` デフォルト除去、`sacrificeMode` 削除、`loadGlobalConfig` キャッシュ、`console.log` → `blankLine()`、`executeTask` options object 化 |

resolved #44
2026-02-01 22:58:49 +09:00

464 lines
13 KiB
TypeScript

/**
* UI utilities for terminal output
*/
import chalk from 'chalk';
import type { StreamEvent, StreamCallback } from '../claude/process.js';
/** Log levels */
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
/** Current log level */
let currentLogLevel: LogLevel = 'info';
/** Log level priorities */
const LOG_PRIORITIES: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
/** Set log level */
export function setLogLevel(level: LogLevel): void {
currentLogLevel = level;
}
/** Check if a log level should be shown */
function shouldLog(level: LogLevel): boolean {
return LOG_PRIORITIES[level] >= LOG_PRIORITIES[currentLogLevel];
}
/** Print a blank line */
export function blankLine(): void {
console.log();
}
/** Log a debug message */
export function debug(message: string): void {
if (shouldLog('debug')) {
console.log(chalk.gray(`[DEBUG] ${message}`));
}
}
/** Log an info message */
export function info(message: string): void {
if (shouldLog('info')) {
console.log(chalk.blue(`[INFO] ${message}`));
}
}
/** Log a warning message */
export function warn(message: string): void {
if (shouldLog('warn')) {
console.log(chalk.yellow(`[WARN] ${message}`));
}
}
/** Log an error message */
export function error(message: string): void {
if (shouldLog('error')) {
console.log(chalk.red(`[ERROR] ${message}`));
}
}
/** Log a success message */
export function success(message: string): void {
console.log(chalk.green(message));
}
/** Print a header */
export function header(title: string): void {
console.log();
console.log(chalk.bold.cyan(`=== ${title} ===`));
console.log();
}
/** Print a section title */
export function section(title: string): void {
console.log(chalk.bold(`\n${title}`));
}
/** Print status */
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 the spinner */
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 the spinner */
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 spinner message */
update(message: string): void {
this.message = message;
}
}
/** Create a progress bar */
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}%`;
}
/** Format a list of items */
export function list(items: string[], bullet = '•'): void {
for (const item of items) {
console.log(chalk.gray(bullet) + ' ' + item);
}
}
/** Print a divider */
export function divider(char = '─', length = 40): void {
console.log(chalk.gray(char.repeat(length)));
}
/** Truncate text with ellipsis */
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,
) {}
/** Display initialization event */
showInit(model: string): void {
if (this.quiet) return;
console.log(chalk.gray(`[${this.agentName}] Model: ${model}`));
}
/** Start spinner for tool execution */
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,
};
}
/** Stop the tool spinner */
private stopToolSpinner(): void {
if (this.toolSpinner) {
clearInterval(this.toolSpinner.intervalId);
// Clear the entire line to avoid artifacts from ANSI color codes
process.stdout.write('\r' + ' '.repeat(120) + '\r');
this.toolSpinner = null;
this.spinnerFrame = 0;
}
}
/** Display tool use event */
showToolUse(tool: string, input: Record<string, unknown>): void {
if (this.quiet) return;
// Clear any buffered text first
this.flushText();
const inputPreview = this.formatToolInput(tool, input);
// Start spinner to show tool is executing
this.startToolSpinner(tool, inputPreview);
this.lastToolUse = tool;
this.currentToolInputPreview = inputPreview;
this.toolOutputBuffer = '';
this.toolOutputPrinted = false;
}
/** Display tool output streaming */
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);
}
}
/** Display tool result event */
showToolResult(content: string, isError: boolean): void {
// Stop the spinner first (always, even in quiet mode to prevent spinner artifacts)
this.stopToolSpinner();
if (this.quiet) {
// In quiet mode: show errors but suppress success messages
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) {
// Show a brief preview of the result
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;
}
/** Display streaming thinking (Claude's internal reasoning) */
showThinking(thinking: string): void {
if (this.quiet) return;
// Stop spinner if running
this.stopToolSpinner();
// Flush any regular text first
this.flushText();
if (this.isFirstThinking) {
console.log();
console.log(chalk.magenta(`💭 [${this.agentName} thinking]:`));
this.isFirstThinking = false;
}
// Write thinking in a dimmed/italic style
process.stdout.write(chalk.gray.italic(thinking));
this.thinkingBuffer += thinking;
}
/** Flush any remaining thinking */
flushThinking(): void {
if (this.thinkingBuffer) {
if (!this.thinkingBuffer.endsWith('\n')) {
console.log();
}
this.thinkingBuffer = '';
this.isFirstThinking = true;
}
}
/** Display streaming text (accumulated) */
showText(text: string): void {
if (this.quiet) return;
// Stop spinner if running
this.stopToolSpinner();
// Flush any thinking first
this.flushThinking();
if (this.isFirstText) {
console.log();
console.log(chalk.cyan(`[${this.agentName}]:`));
this.isFirstText = false;
}
// Write directly to stdout without newline for smooth streaming
process.stdout.write(text);
this.textBuffer += text;
}
/** Flush any remaining text */
flushText(): void {
if (this.textBuffer) {
// Ensure we end with a newline
if (!this.textBuffer.endsWith('\n')) {
console.log();
}
this.textBuffer = '';
this.isFirstText = true;
}
}
/** Flush both thinking and text buffers */
flush(): void {
this.stopToolSpinner();
this.flushThinking();
this.flushText();
}
/** Display final result */
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 state for new interaction */
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;
}
/**
* Create a stream event handler for this display.
* This centralizes the event handling logic to avoid code duplication.
*/
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':
// Parse errors are logged but not displayed to user
break;
}
};
}
/** Format tool input for display */
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}`));
}
}
}