diff --git a/CLAUDE.md b/CLAUDE.md index a37195d1..abee3f9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ Frontend (web:3001 / desktop) → Agent Engine (LLM runner, sessions, skills, tools) ``` -**Agent Engine** (`src/agent/`): Orchestrates LLM interactions with multi-provider support (OpenAI, Anthropic, DeepSeek, Kimi, Groq, Mistral, Google, Together). Features session management (JSONL-based, UUIDv7 IDs), profile system (`~/.super-multica/agent-profiles/`), modular skills with hot-reload, and token-aware context window guards (compaction modes: tokens, count, summary). +**Agent Engine** (`src/agent/`): Orchestrates LLM interactions with multi-provider support (OpenAI, Anthropic, DeepSeek, Kimi, Groq, Mistral, Google, Together). Features session management (JSONL-based, UUIDv7 IDs), profile system (`~/.super-multica/agent-profiles/`), modular skills with hot-reload, and token-aware context window guards (compaction modes: tokens, count, summary). CLI tools are organized in `src/agent/cli/` (interactive, non-interactive, profile, skills, tools). **Gateway** (`src/gateway/`): NestJS WebSocket server with Socket.io for real-time message passing, RPC request/response, and streaming. diff --git a/package.json b/package.json index e385255c..68adb791 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,11 @@ }, "scripts": { "dev": "concurrently -n gateway,console,web -c blue,yellow,green \"pnpm dev:gateway\" \"pnpm dev:console\" \"pnpm dev:web\"", - "agent:cli": "tsx --env-file=.env src/agent/cli.ts", - "agent:interactive": "tsx --env-file=.env src/agent/interactive-cli.ts", - "agent:profile": "tsx --env-file=.env src/agent/profile-cli.ts", - "skills:cli": "tsx --env-file=.env src/agent/skills-cli.ts", - "tools:cli": "tsx --env-file=.env src/agent/tools-cli.ts", + "agent:cli": "tsx --env-file=.env src/agent/cli/non-interactive.ts", + "agent:interactive": "tsx --env-file=.env src/agent/cli/interactive.ts", + "agent:profile": "tsx --env-file=.env src/agent/cli/profile.ts", + "skills:cli": "tsx --env-file=.env src/agent/cli/skills.ts", + "tools:cli": "tsx --env-file=.env src/agent/cli/tools.ts", "dev:gateway": "tsx --env-file=.env --watch src/gateway/main.ts", "dev:console": "tsx --env-file=.env --watch src/console/main.ts", "dev:web": "pnpm --filter @multica/web dev", diff --git a/scripts/build-cli.js b/scripts/build-cli.js index 85f814eb..cae9e43d 100644 --- a/scripts/build-cli.js +++ b/scripts/build-cli.js @@ -29,9 +29,9 @@ const stripShebangPlugin = { async function build() { const entryPoints = [ - { entry: "src/agent/interactive-cli.ts", outfile: "bin/multica-interactive.mjs" }, - { entry: "src/agent/cli.ts", outfile: "bin/multica-cli.mjs" }, - { entry: "src/agent/profile-cli.ts", outfile: "bin/multica-profile.mjs" }, + { entry: "src/agent/cli/interactive.ts", outfile: "bin/multica-interactive.mjs" }, + { entry: "src/agent/cli/non-interactive.ts", outfile: "bin/multica-cli.mjs" }, + { entry: "src/agent/cli/profile.ts", outfile: "bin/multica-profile.mjs" }, ]; for (const { entry, outfile } of entryPoints) { diff --git a/src/agent/autocomplete.ts b/src/agent/cli/autocomplete.ts similarity index 92% rename from src/agent/autocomplete.ts rename to src/agent/cli/autocomplete.ts index 302e078b..e12ac87a 100644 --- a/src/agent/autocomplete.ts +++ b/src/agent/cli/autocomplete.ts @@ -6,6 +6,7 @@ */ import * as readline from "readline"; +import { colors } from "./colors.js"; export interface AutocompleteOption { value: string; @@ -26,7 +27,6 @@ const ESC = "\x1b"; const CLEAR_LINE = `${ESC}[2K`; const CURSOR_UP = (n: number) => (n > 0 ? `${ESC}[${n}A` : ""); const CURSOR_TO_COL = (n: number) => `${ESC}[${n}G`; -const DIM = `${ESC}[2m`; const RESET = `${ESC}[0m`; const INVERSE = `${ESC}[7m`; const SHOW_CURSOR = `${ESC}[?25h`; @@ -35,6 +35,12 @@ const RESTORE_CURSOR = `${ESC}[u`; const CLEAR_TO_END = `${ESC}[J`; const CURSOR_DOWN = (n: number) => (n > 0 ? `${ESC}[${n}B` : ""); +// Strip ANSI escape codes to get visual length +const ANSI_REGEX = /\x1b\[[0-9;]*m/g; +function stripAnsi(str: string): string { + return str.replace(ANSI_REGEX, ""); +} + /** * Read a line with real-time autocomplete dropdown */ @@ -83,7 +89,8 @@ export function autocompleteInput(config: AutocompleteConfig): Promise { // Calculate cursor position accounting for line wrapping const termWidth = stdout.columns || 80; - const cursorOffset = prompt.length + cursorPos; + const promptVisualLen = stripAnsi(prompt).length; + const cursorOffset = promptVisualLen + cursorPos; // Handle edge case: when cursor is exactly at line boundary, // show it at end of current line, not start of next line @@ -98,7 +105,7 @@ export function autocompleteInput(config: AutocompleteConfig): Promise { } // Calculate total lines for suggestions positioning - const totalLength = prompt.length + input.length; + const totalLength = promptVisualLen + input.length; const totalLines = Math.ceil(totalLength / termWidth) || 1; // Get and display suggestions if input starts with / @@ -118,10 +125,11 @@ export function autocompleteInput(config: AutocompleteConfig): Promise { for (let i = 0; i < suggestions.length; i++) { const opt = suggestions[i]!; const isSelected = i === selectedIndex; - const prefix = isSelected ? `${INVERSE}` : `${DIM}`; - const suffix = RESET; - const label = opt.label ? ` ${DIM}${opt.label}${RESET}` : ""; - const line = `${prefix} ${opt.value}${suffix}${label}`; + const value = isSelected + ? `${INVERSE} ${opt.value}${RESET}` + : ` ${colors.suggestionDim(opt.value)}`; + const label = opt.label ? ` ${colors.suggestionLabel(opt.label)}` : ""; + const line = `${value}${label}`; stdout.write(`${CLEAR_LINE}${line}`); if (i < suggestions.length - 1) { diff --git a/src/agent/cli/colors.ts b/src/agent/cli/colors.ts new file mode 100644 index 00000000..37e22ee5 --- /dev/null +++ b/src/agent/cli/colors.ts @@ -0,0 +1,151 @@ +/** + * Terminal Colors and Styling + * + * Simple ANSI color utilities for terminal output + */ + +// Check if colors should be disabled +const NO_COLOR = process.env.NO_COLOR !== undefined || process.env.TERM === "dumb"; + +type StyleFn = (s: string) => string; + +const identity: StyleFn = (s) => s; + +function style(code: number, reset: number = 0): StyleFn { + if (NO_COLOR) return identity; + return (s: string) => `\x1b[${code}m${s}\x1b[${reset}m`; +} + +// Basic styles +export const reset = "\x1b[0m"; +export const bold = style(1, 22); +export const dim = style(2, 22); +export const italic = style(3, 23); +export const underline = style(4, 24); +export const inverse = style(7, 27); + +// Foreground colors +export const black = style(30, 39); +export const red = style(31, 39); +export const green = style(32, 39); +export const yellow = style(33, 39); +export const blue = style(34, 39); +export const magenta = style(35, 39); +export const cyan = style(36, 39); +export const white = style(37, 39); +export const gray = style(90, 39); + +// Bright colors +export const brightRed = style(91, 39); +export const brightGreen = style(92, 39); +export const brightYellow = style(93, 39); +export const brightBlue = style(94, 39); +export const brightMagenta = style(95, 39); +export const brightCyan = style(96, 39); + +// Background colors +export const bgRed = style(41, 49); +export const bgGreen = style(42, 49); +export const bgYellow = style(43, 49); +export const bgBlue = style(44, 49); + +// Semantic colors for the CLI +export const colors = { + // UI elements + prompt: cyan, + promptSymbol: brightCyan, + sessionId: dim, + + // Tool output + toolName: yellow, + toolArgs: dim, + toolBullet: cyan, + toolArrow: dim, + toolError: red, + + // Messages + error: red, + warning: yellow, + success: green, + info: blue, + + // Status bar + statusBg: inverse, + statusLabel: dim, + statusValue: white, + + // Welcome banner + bannerBorder: cyan, + bannerText: brightCyan, + + // Suggestions + suggestionSelected: inverse, + suggestionDim: dim, + suggestionLabel: gray, +}; + +// Spinner frames for thinking indicator +export const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +// Alternative spinner styles +export const spinnerStyles = { + dots: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], + line: ["-", "\\", "|", "/"], + arc: ["◜", "◠", "◝", "◞", "◡", "◟"], + bounce: ["⠁", "⠂", "⠄", "⠂"], + pulse: ["◯", "◔", "◑", "◕", "●", "◕", "◑", "◔"], +}; + +/** + * Create a spinner instance + */ +export function createSpinner(options: { + stream?: NodeJS.WritableStream; + frames?: string[]; + interval?: number; +} = {}) { + const { + stream = process.stderr, + frames = spinnerFrames, + interval = 80, + } = options; + + let frameIndex = 0; + let timer: ReturnType | null = null; + let currentText = ""; + + const render = () => { + const frame = colors.toolBullet(frames[frameIndex % frames.length]!); + stream.write(`\r\x1b[K${frame} ${currentText}`); + frameIndex++; + }; + + return { + start(text: string) { + currentText = text; + frameIndex = 0; + if (timer) clearInterval(timer); + render(); + timer = setInterval(render, interval); + }, + + update(text: string) { + currentText = text; + }, + + stop(finalText?: string) { + if (timer) { + clearInterval(timer); + timer = null; + } + stream.write("\r\x1b[K"); + if (finalText) { + stream.write(finalText + "\n"); + } + }, + + isSpinning() { + return timer !== null; + }, + }; +} diff --git a/src/agent/interactive-cli.ts b/src/agent/cli/interactive.ts similarity index 51% rename from src/agent/interactive-cli.ts rename to src/agent/cli/interactive.ts index 6d6c9edb..68ccd68c 100644 --- a/src/agent/interactive-cli.ts +++ b/src/agent/cli/interactive.ts @@ -1,9 +1,10 @@ #!/usr/bin/env node import * as readline from "readline"; -import { Agent } from "./runner.js"; -import type { AgentOptions } from "./types.js"; -import { SkillManager } from "./skills/index.js"; +import { Agent } from "../runner.js"; +import type { AgentOptions } from "../types.js"; +import { SkillManager } from "../skills/index.js"; import { autocompleteInput, type AutocompleteOption } from "./autocomplete.js"; +import { colors, dim, cyan, brightCyan, yellow, green, gray } from "./colors.js"; type CliOptions = { profile?: string | undefined; @@ -26,21 +27,21 @@ const COMMANDS = { }; function printUsage() { - console.log("Usage: pnpm agent:interactive [options]"); + console.log(`${cyan("Usage:")} pnpm agent:interactive [options]`); console.log(""); - console.log("Options:"); - console.log(" --profile ID Load agent profile (identity, soul, tools, memory)"); - console.log(" --provider NAME LLM provider (e.g., openai, anthropic, kimi)"); - console.log(" --model NAME Model name"); - console.log(" --system TEXT System prompt (ignored if --profile is set)"); - console.log(" --thinking LEVEL Thinking level"); - console.log(" --cwd DIR Working directory for commands"); - console.log(" --session ID Session ID to resume"); - console.log(" --help, -h Show this help"); + console.log(`${cyan("Options:")}`); + console.log(` ${yellow("--profile")} ID Load agent profile (identity, soul, tools, memory)`); + console.log(` ${yellow("--provider")} NAME LLM provider (e.g., openai, anthropic, kimi)`); + console.log(` ${yellow("--model")} NAME Model name`); + console.log(` ${yellow("--system")} TEXT System prompt (ignored if --profile is set)`); + console.log(` ${yellow("--thinking")} LEVEL Thinking level`); + console.log(` ${yellow("--cwd")} DIR Working directory for commands`); + console.log(` ${yellow("--session")} ID Session ID to resume`); + console.log(` ${yellow("--help")}, -h Show this help`); console.log(""); - console.log("Commands (use during interaction):"); + console.log(`${cyan("Commands")} (use during interaction):`); for (const [cmd, desc] of Object.entries(COMMANDS)) { - console.log(` /${cmd.padEnd(12)} ${desc}`); + console.log(` ${yellow(`/${cmd}`.padEnd(14))} ${dim(desc)}`); } } @@ -88,19 +89,37 @@ function parseArgs(argv: string[]) { return opts; } -function printWelcome(sessionId: string) { - console.log("╭─────────────────────────────────────────╮"); - console.log("│ Super Multica Interactive CLI │"); - console.log("╰─────────────────────────────────────────╯"); - console.log(`Session: ${sessionId}`); - console.log("Type /help for available commands, /exit to quit."); +function printWelcome(sessionId: string, opts: CliOptions) { + const border = cyan("│"); + const topBorder = cyan("╭─────────────────────────────────────────╮"); + const bottomBorder = cyan("╰─────────────────────────────────────────╯"); + + console.log(topBorder); + console.log(`${border} ${brightCyan("Super Multica Interactive CLI")} ${border}`); + console.log(bottomBorder); + + // Show configuration + const configLines: string[] = []; + configLines.push(`${dim("Session:")} ${gray(sessionId.slice(0, 8))}...`); + if (opts.profile) { + configLines.push(`${dim("Profile:")} ${yellow(opts.profile)}`); + } + if (opts.provider) { + configLines.push(`${dim("Provider:")} ${green(opts.provider)}`); + } + if (opts.model) { + configLines.push(`${dim("Model:")} ${green(opts.model)}`); + } + + console.log(configLines.join(" ")); + console.log(`${dim("Type")} ${cyan("/help")} ${dim("for commands,")} ${cyan("/exit")} ${dim("to quit.")}`); console.log(""); } function printHelp(skillManager?: SkillManager) { - console.log("\nBuilt-in commands:"); + console.log(`\n${cyan("Built-in commands:")}`); for (const [cmd, desc] of Object.entries(COMMANDS)) { - console.log(` /${cmd.padEnd(12)} ${desc}`); + console.log(` ${yellow(`/${cmd}`.padEnd(14))} ${dim(desc)}`); } // Show skill commands if available @@ -108,30 +127,125 @@ function printHelp(skillManager?: SkillManager) { const reservedNames = new Set(Object.keys(COMMANDS)); const skillCommands = skillManager.getSkillCommands({ reservedNames }); if (skillCommands.length > 0) { - console.log("\nSkill commands:"); + console.log(`\n${cyan("Skill commands:")}`); for (const cmd of skillCommands) { - console.log(` /${cmd.name.padEnd(12)} ${cmd.description}`); + console.log(` ${yellow(`/${cmd.name}`.padEnd(14))} ${dim(cmd.description)}`); } } } - console.log("\nJust type your message and press Enter to chat with the agent."); + console.log(`\n${dim("Just type your message and press Enter to chat with the agent.")}`); console.log(""); } +/** + * Status Bar - renders a persistent status line at the bottom of the terminal + */ +class StatusBar { + private enabled: boolean; + private currentStatus: string = ""; + private stream: NodeJS.WriteStream; + + constructor(stream: NodeJS.WriteStream = process.stdout) { + this.stream = stream; + this.enabled = stream.isTTY === true; + } + + /** + * Update the status bar content + */ + update(parts: { session?: string; provider?: string; model?: string; tokens?: number }) { + if (!this.enabled) return; + + const segments: string[] = []; + + if (parts.session) { + segments.push(`${dim("session:")}${gray(parts.session.slice(0, 8))}`); + } + if (parts.provider) { + segments.push(`${dim("provider:")}${green(parts.provider)}`); + } + if (parts.model) { + segments.push(`${dim("model:")}${yellow(parts.model)}`); + } + if (parts.tokens !== undefined) { + segments.push(`${dim("tokens:")}${cyan(String(parts.tokens))}`); + } + + this.currentStatus = segments.join(" "); + this.render(); + } + + /** + * Render the status bar + */ + private render() { + if (!this.enabled || !this.currentStatus) return; + + const termWidth = this.stream.columns || 80; + const termHeight = this.stream.rows || 24; + + // Save cursor, move to bottom, clear line, write status, restore cursor + const statusLine = ` ${this.currentStatus} `.slice(0, termWidth); + + this.stream.write( + `\x1b[s` + // Save cursor + `\x1b[${termHeight};1H` + // Move to last row + `\x1b[7m` + // Inverse video (highlight) + `\x1b[2K` + // Clear line + statusLine.padEnd(termWidth) + // Write status padded to terminal width + `\x1b[0m` + // Reset + `\x1b[u` // Restore cursor + ); + } + + /** + * Clear the status bar + */ + clear() { + if (!this.enabled) return; + + const termHeight = this.stream.rows || 24; + + this.stream.write( + `\x1b[s` + // Save cursor + `\x1b[${termHeight};1H` + // Move to last row + `\x1b[2K` + // Clear line + `\x1b[u` // Restore cursor + ); + this.currentStatus = ""; + } + + /** + * Temporarily hide status bar (for clean output) + */ + hide() { + this.clear(); + } + + /** + * Show status bar again + */ + show() { + this.render(); + } +} + class InteractiveCLI { private agent: Agent; private opts: CliOptions; - private rl: readline.Interface; + private rl: readline.Interface | null = null; private multilineMode = false; private multilineBuffer: string[] = []; private running = true; private skillManager: SkillManager; private reservedNames: Set; + private statusBar: StatusBar; constructor(opts: CliOptions) { this.opts = opts; this.agent = this.createAgent(opts.session); + this.statusBar = new StatusBar(); // Initialize SkillManager for tab completion this.skillManager = new SkillManager({ @@ -141,19 +255,46 @@ class InteractiveCLI { // Build list of reserved command names (built-in CLI commands) this.reservedNames = new Set(Object.keys(COMMANDS)); - this.rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - terminal: true, - }); - - this.rl.on("close", () => { - this.running = false; - console.log("\nGoodbye!"); + // Handle Ctrl+C gracefully + process.on("SIGINT", () => { + this.statusBar.clear(); + console.log(`\n${dim("Goodbye!")}`); process.exit(0); }); } + /** + * Get or create readline interface (lazy initialization) + * Only created when needed for multiline mode to avoid interfering with autocomplete + */ + private getReadline(): readline.Interface { + if (!this.rl) { + this.rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: true, + }); + + this.rl.on("close", () => { + this.running = false; + this.statusBar.clear(); + console.log(`\n${dim("Goodbye!")}`); + process.exit(0); + }); + } + return this.rl; + } + + /** + * Close readline interface when not needed + */ + private closeReadline() { + if (this.rl) { + this.rl.close(); + this.rl = null; + } + } + /** * Get autocomplete suggestions for input */ @@ -209,13 +350,25 @@ class InteractiveCLI { private prompt(): string { if (this.multilineMode) { - return this.multilineBuffer.length === 0 ? ">>> " : "... "; + return this.multilineBuffer.length === 0 ? cyan(">>> ") : cyan("... "); } - return "You: "; + return `${brightCyan("You:")} `; + } + + private updateStatusBar() { + const statusUpdate: { session?: string; provider?: string; model?: string; tokens?: number } = { + session: this.agent.sessionId, + provider: this.opts.provider ?? "default", + }; + if (this.opts.model) { + statusUpdate.model = this.opts.model; + } + this.statusBar.update(statusUpdate); } async run() { - printWelcome(this.agent.sessionId); + printWelcome(this.agent.sessionId, this.opts); + this.updateStatusBar(); await this.loop(); } @@ -234,6 +387,8 @@ class InteractiveCLI { const fullInput = this.multilineBuffer.join("\n"); this.multilineBuffer = []; this.multilineMode = false; + // Close readline to avoid interfering with autocomplete + this.closeReadline(); if (fullInput.trim()) { await this.handleInput(fullInput); } @@ -245,11 +400,13 @@ class InteractiveCLI { // Use autocomplete input for normal mode try { + this.statusBar.hide(); input = await autocompleteInput({ prompt: this.prompt(), getSuggestions: (text) => this.getSuggestions(text), maxSuggestions: 8, }); + this.statusBar.show(); } catch { break; } @@ -270,7 +427,7 @@ class InteractiveCLI { private readline(prompt: string): Promise { return new Promise((resolve) => { - this.rl.question(prompt, (answer) => { + this.getReadline().question(prompt, (answer) => { resolve(answer); }); }); @@ -287,34 +444,39 @@ class InteractiveCLI { case "exit": case "quit": case "q": - console.log("Goodbye!"); + this.statusBar.clear(); + console.log(dim("Goodbye!")); this.running = false; - this.rl.close(); + this.closeReadline(); process.exit(0); return true; case "clear": this.agent = this.createAgent(); - console.log(`Session cleared. New session: ${this.agent.sessionId}\n`); + this.updateStatusBar(); + console.log(`${green("Session cleared.")} ${dim("New session:")} ${gray(this.agent.sessionId.slice(0, 8))}...\n`); return true; case "session": - console.log(`Current session: ${this.agent.sessionId}\n`); + console.log(`${dim("Current session:")} ${cyan(this.agent.sessionId)}\n`); return true; case "new": this.agent = this.createAgent(); - console.log(`Started new session: ${this.agent.sessionId}\n`); + this.updateStatusBar(); + console.log(`${green("Started new session:")} ${gray(this.agent.sessionId.slice(0, 8))}...\n`); return true; case "multiline": this.multilineMode = !this.multilineMode; if (this.multilineMode) { - console.log("Multi-line mode enabled. End input with a line containing only '.'"); + console.log(`${green("Multi-line mode enabled.")} ${dim("End input with a line containing only '.'")}`); this.multilineBuffer = []; } else { - console.log("Multi-line mode disabled."); + console.log(dim("Multi-line mode disabled.")); this.multilineBuffer = []; + // Close readline to avoid interfering with autocomplete + this.closeReadline(); } return true; @@ -337,13 +499,15 @@ class InteractiveCLI { private async handleInput(input: string) { try { console.log(""); // Add spacing before response + this.statusBar.hide(); const result = await this.agent.run(input); + this.statusBar.show(); if (result.error) { - console.error(`\nError: ${result.error}`); + console.error(`\n${colors.error(`Error: ${result.error}`)}`); } console.log(""); // Add spacing after response } catch (err) { - console.error(`\nError: ${err instanceof Error ? err.message : String(err)}`); + console.error(`\n${colors.error(`Error: ${err instanceof Error ? err.message : String(err)}`)}`); console.log(""); } } @@ -359,7 +523,7 @@ async function main() { // Check if running in a TTY if (!process.stdin.isTTY) { - console.error("Error: Interactive CLI requires a TTY. Use agent:cli for non-interactive mode."); + console.error(colors.error("Error: Interactive CLI requires a TTY. Use agent:cli for non-interactive mode.")); process.exit(1); } diff --git a/src/agent/cli.ts b/src/agent/cli/non-interactive.ts similarity index 98% rename from src/agent/cli.ts rename to src/agent/cli/non-interactive.ts index ab4d1417..f0109209 100644 --- a/src/agent/cli.ts +++ b/src/agent/cli/non-interactive.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { Agent } from "./runner.js"; +import { Agent } from "../runner.js"; type CliOptions = { profile?: string | undefined; @@ -149,7 +149,7 @@ async function main() { } // Build tools config if any tools options are set - let toolsConfig: import("./tools/policy.js").ToolsConfig | undefined; + let toolsConfig: import("../tools/policy.js").ToolsConfig | undefined; if (opts.toolsProfile || opts.toolsAllow || opts.toolsDeny) { toolsConfig = {}; if (opts.toolsProfile) { diff --git a/src/agent/output.ts b/src/agent/cli/output.ts similarity index 69% rename from src/agent/output.ts rename to src/agent/cli/output.ts index e5132fc2..fe66b261 100644 --- a/src/agent/output.ts +++ b/src/agent/cli/output.ts @@ -1,4 +1,5 @@ import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core"; +import { colors, createSpinner } from "./colors.js"; export type AgentOutputState = { lastAssistantText: string; @@ -62,9 +63,13 @@ function formatToolArgs(name: string, args: unknown): string { } function formatToolLine(name: string, args: unknown): string { - const title = toolDisplayName(name); + const title = colors.toolName(toolDisplayName(name)); const argText = formatToolArgs(name, args); - return argText ? `• Used ${title} (${argText})` : `• Used ${title}`; + const bullet = colors.toolBullet("•"); + if (argText) { + return `${bullet} ${title} ${colors.toolArgs(`(${argText})`)}`; + } + return `${bullet} ${title}`; } export function createAgentOutput(params: { @@ -77,11 +82,20 @@ export function createAgentOutput(params: { streaming: false, }; + // Create spinner for thinking indicator + const spinner = createSpinner({ stream: params.stderr }); + let pendingToolName = ""; + let pendingToolArgs: unknown = null; + const handleEvent = (event: AgentEvent) => { switch (event.type) { case "message_start": { const msg = event.message; if (msg.role === "assistant") { + // Stop any running spinner when assistant starts responding + if (spinner.isSpinning()) { + spinner.stop(); + } state.streaming = true; state.printedLen = 0; const text = extractText(msg); @@ -117,26 +131,39 @@ export function createAgentOutput(params: { } break; } - case "tool_execution_start": - params.stderr.write(`${formatToolLine(event.toolName, event.args)}\n`); + case "tool_execution_start": { + pendingToolName = event.toolName; + pendingToolArgs = event.args; + const title = colors.toolName(toolDisplayName(event.toolName)); + const argText = formatToolArgs(event.toolName, event.args); + const displayText = argText ? `${title} ${colors.toolArgs(`(${argText})`)}` : title; + spinner.start(displayText); break; + } case "tool_execution_update": { // Show real-time output updates (e.g., from exec tool) const updateText = extractText(event.partialResult); - if (updateText) { - // Clear line and show latest tail output - params.stderr.write(`\r\x1b[K ↳ ${updateText.slice(-60).replace(/\n/g, " ")}`); + if (updateText && pendingToolName) { + const title = colors.toolName(toolDisplayName(pendingToolName)); + const preview = colors.toolArgs(updateText.slice(-50).replace(/\n/g, " ")); + spinner.update(`${title} ${colors.toolArrow("→")} ${preview}`); } break; } - case "tool_execution_end": - // Clear any update line from tool_execution_update - params.stderr.write("\r\x1b[K"); + case "tool_execution_end": { + // Stop spinner and show final result if (event.isError) { const errorText = extractText(event.result) || "Tool failed"; - params.stderr.write(`• Tool error (${toolDisplayName(event.toolName)}): ${errorText}\n`); + const bullet = colors.toolError("✗"); + const title = colors.toolName(toolDisplayName(event.toolName)); + spinner.stop(`${bullet} ${title}: ${colors.toolError(errorText)}`); + } else { + spinner.stop(formatToolLine(event.toolName, pendingToolArgs)); } + pendingToolName = ""; + pendingToolArgs = null; break; + } default: break; } diff --git a/src/agent/profile-cli.ts b/src/agent/cli/profile.ts similarity index 98% rename from src/agent/profile-cli.ts rename to src/agent/cli/profile.ts index d3055734..b8a30177 100644 --- a/src/agent/profile-cli.ts +++ b/src/agent/cli/profile.ts @@ -16,8 +16,8 @@ import { loadAgentProfile, getProfileDir, profileExists, -} from "./profile/index.js"; -import { DATA_DIR } from "../shared/index.js"; +} from "../profile/index.js"; +import { DATA_DIR } from "../../shared/index.js"; const DEFAULT_BASE_DIR = join(DATA_DIR, "agent-profiles"); diff --git a/src/agent/skills-cli.ts b/src/agent/cli/skills.ts similarity index 99% rename from src/agent/skills-cli.ts rename to src/agent/cli/skills.ts index ba3798d2..b00457a5 100644 --- a/src/agent/skills-cli.ts +++ b/src/agent/cli/skills.ts @@ -21,7 +21,7 @@ import { listInstalledSkills, checkEligibilityDetailed, type DiagnosticItem, -} from "./skills/index.js"; +} from "../skills/index.js"; // ============================================================================ // Types diff --git a/src/agent/tools-cli.ts b/src/agent/cli/tools.ts similarity index 95% rename from src/agent/tools-cli.ts rename to src/agent/cli/tools.ts index 7a88c531..d0301a5e 100644 --- a/src/agent/tools-cli.ts +++ b/src/agent/cli/tools.ts @@ -10,9 +10,9 @@ * pnpm tools:cli profiles # Show all profiles */ -import { createAllTools } from "./tools.js"; -import { filterTools, type ToolsConfig } from "./tools/policy.js"; -import { TOOL_GROUPS, TOOL_PROFILES, expandToolGroups } from "./tools/groups.js"; +import { createAllTools } from "../tools.js"; +import { filterTools, type ToolsConfig } from "../tools/policy.js"; +import { TOOL_GROUPS, TOOL_PROFILES, expandToolGroups } from "../tools/groups.js"; type Command = "list" | "groups" | "profiles" | "help"; @@ -109,7 +109,7 @@ function listTools(opts: CliOptions) { } } - const filterOpts: import("./tools/policy.js").FilterToolsOptions = {}; + const filterOpts: import("../tools/policy.js").FilterToolsOptions = {}; if (config) { filterOpts.config = config; } diff --git a/src/agent/runner.ts b/src/agent/runner.ts index 1ccb3f7d..441af311 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -1,7 +1,7 @@ import { Agent as PiAgentCore, type AgentEvent, type AgentMessage } from "@mariozechner/pi-agent-core"; import { v7 as uuidv7 } from "uuid"; import type { AgentOptions, AgentRunResult } from "./types.js"; -import { createAgentOutput } from "./output.js"; +import { createAgentOutput } from "./cli/output.js"; import { resolveModel, resolveTools } from "./tools.js"; import { SessionManager } from "./session/session-manager.js"; import { ProfileManager } from "./profile/index.js";