feat(agent): enhance interactive CLI with colors, spinner, and status bar (#43)

* feat(agent): improve interactive CLI with colors, spinner, and status bar

- Add colors.ts module with ANSI terminal color utilities
- Add spinner animation for tool execution feedback
- Add persistent status bar showing session/provider/model
- Apply colors to welcome banner, prompts, commands, and suggestions
- Support NO_COLOR env for accessibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(agent): correct cursor position with ANSI-colored prompts

Strip ANSI escape codes when calculating visual length of prompt
to ensure cursor is positioned correctly after colored text.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(agent): prevent duplicate input echo in interactive CLI

Lazy-initialize readline.Interface only when multiline mode is active.
This prevents readline from interfering with autocomplete's raw mode,
which was causing user input to be echoed twice.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(agent): move CLI files to dedicated cli/ directory

Reorganize CLI-related files into src/agent/cli/ for better separation:
- interactive.ts (was interactive-cli.ts)
- non-interactive.ts (was cli.ts)
- profile.ts, skills.ts, tools.ts (was *-cli.ts)
- autocomplete.ts, colors.ts, output.ts (CLI utilities)

Update all imports, package.json scripts, and build configuration.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jiayuan Zhang 2026-01-31 21:07:07 +08:00 committed by GitHub
parent b7e1063b3a
commit b1d80f29ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 437 additions and 87 deletions

View file

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

View file

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

View file

@ -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) {

View file

@ -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<string> {
// 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<string> {
}
// 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<string> {
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) {

151
src/agent/cli/colors.ts Normal file
View file

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

View file

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

View file

@ -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) {

View file

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

View file

@ -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");

View file

@ -21,7 +21,7 @@ import {
listInstalledSkills,
checkEligibilityDetailed,
type DiagnosticItem,
} from "./skills/index.js";
} from "../skills/index.js";
// ============================================================================
// Types

View file

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

View file

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