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:
parent
b7e1063b3a
commit
b1d80f29ae
12 changed files with 437 additions and 87 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
10
package.json
10
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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
151
src/agent/cli/colors.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
@ -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) {
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
||||
|
|
@ -21,7 +21,7 @@ import {
|
|||
listInstalledSkills,
|
||||
checkEligibilityDetailed,
|
||||
type DiagnosticItem,
|
||||
} from "./skills/index.js";
|
||||
} from "../skills/index.js";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue