@mewbleh/purrx 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +31 -0
- package/LICENSE +201 -0
- package/README.md +314 -0
- package/bin/purrx.js +352 -0
- package/package.json +64 -0
- package/src/api/client.js +121 -0
- package/src/api/models.js +57 -0
- package/src/auth/login.js +199 -0
- package/src/auth/pkce.js +34 -0
- package/src/auth/tokens.js +186 -0
- package/src/config.js +57 -0
- package/src/core/agent.js +197 -0
- package/src/core/approval.js +101 -0
- package/src/core/compact.js +207 -0
- package/src/core/context.js +245 -0
- package/src/core/session.js +101 -0
- package/src/index.js +24 -0
- package/src/platform.js +94 -0
- package/src/tools/builtin.js +476 -0
- package/src/tools/mcp.js +223 -0
- package/src/tools/registry.js +62 -0
- package/src/types.js +68 -0
- package/src/ui/render.js +47 -0
- package/src/ui/theme.js +114 -0
- package/src/ui/tui.js +317 -0
package/src/tools/mcp.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { configFilePath } from "../config.js";
|
|
4
|
+
|
|
5
|
+
// Minimal MCP (Model Context Protocol) client over stdio using JSON-RPC 2.0
|
|
6
|
+
// with newline-delimited framing. Connects to configured servers, lists their
|
|
7
|
+
// tools, and proxies tools/call requests.
|
|
8
|
+
|
|
9
|
+
const PROTOCOL_VERSION = "2024-11-05";
|
|
10
|
+
|
|
11
|
+
class McpServer {
|
|
12
|
+
constructor(name, config) {
|
|
13
|
+
this.name = name;
|
|
14
|
+
this.config = config;
|
|
15
|
+
this.proc = null;
|
|
16
|
+
this.nextId = 1;
|
|
17
|
+
this.pending = new Map();
|
|
18
|
+
this.buffer = "";
|
|
19
|
+
this.tools = [];
|
|
20
|
+
this.ready = false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async start() {
|
|
24
|
+
const { command, args = [], env = {} } = this.config;
|
|
25
|
+
// On Windows, launchers like `npx` resolve to `.cmd` shims that require a
|
|
26
|
+
// shell. MCP configs come from the user's own trusted config file, so the
|
|
27
|
+
// shell concatenation risk is acceptable here.
|
|
28
|
+
this.proc = spawn(command, args, {
|
|
29
|
+
env: { ...process.env, ...env },
|
|
30
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
31
|
+
shell: process.platform === "win32",
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
this.proc.stdout.setEncoding("utf8");
|
|
35
|
+
this.proc.stdout.on("data", (chunk) => this._onData(chunk));
|
|
36
|
+
this.proc.stderr.setEncoding("utf8");
|
|
37
|
+
this.proc.stderr.on("data", (data) => {
|
|
38
|
+
// MCP servers commonly log to stderr; ignore unless debugging.
|
|
39
|
+
if (process.env.PURRX_DEBUG) {
|
|
40
|
+
process.stderr.write(`[mcp:${this.name}] ${data}`);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
this.proc.on("error", (err) => {
|
|
44
|
+
for (const { reject } of this.pending.values()) reject(err);
|
|
45
|
+
this.pending.clear();
|
|
46
|
+
});
|
|
47
|
+
this.proc.on("exit", () => {
|
|
48
|
+
this.ready = false;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Handshake.
|
|
52
|
+
await this._request("initialize", {
|
|
53
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
54
|
+
capabilities: { tools: {} },
|
|
55
|
+
clientInfo: { name: "purrx", version: "1.0.0" },
|
|
56
|
+
});
|
|
57
|
+
this._notify("notifications/initialized", {});
|
|
58
|
+
this.ready = true;
|
|
59
|
+
|
|
60
|
+
// Discover tools.
|
|
61
|
+
const result = await this._request("tools/list", {});
|
|
62
|
+
this.tools = result?.tools || [];
|
|
63
|
+
return this.tools;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
_onData(chunk) {
|
|
67
|
+
this.buffer += chunk;
|
|
68
|
+
let idx;
|
|
69
|
+
while ((idx = this.buffer.indexOf("\n")) !== -1) {
|
|
70
|
+
const line = this.buffer.slice(0, idx).trim();
|
|
71
|
+
this.buffer = this.buffer.slice(idx + 1);
|
|
72
|
+
if (!line) continue;
|
|
73
|
+
let msg;
|
|
74
|
+
try {
|
|
75
|
+
msg = JSON.parse(line);
|
|
76
|
+
} catch {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (msg.id !== undefined && this.pending.has(msg.id)) {
|
|
80
|
+
const { resolve, reject } = this.pending.get(msg.id);
|
|
81
|
+
this.pending.delete(msg.id);
|
|
82
|
+
if (msg.error) {
|
|
83
|
+
reject(new Error(msg.error.message || "MCP error"));
|
|
84
|
+
} else {
|
|
85
|
+
resolve(msg.result);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Notifications from server are ignored.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
_send(obj) {
|
|
93
|
+
if (!this.proc || !this.proc.stdin.writable) {
|
|
94
|
+
throw new Error(`MCP server ${this.name} is not running`);
|
|
95
|
+
}
|
|
96
|
+
this.proc.stdin.write(JSON.stringify(obj) + "\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
_request(method, params) {
|
|
100
|
+
const id = this.nextId++;
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
this.pending.set(id, { resolve, reject });
|
|
103
|
+
this._send({ jsonrpc: "2.0", id, method, params });
|
|
104
|
+
// Timeout so a hung server doesn't block forever.
|
|
105
|
+
setTimeout(() => {
|
|
106
|
+
if (this.pending.has(id)) {
|
|
107
|
+
this.pending.delete(id);
|
|
108
|
+
reject(new Error(`MCP ${this.name} ${method} timed out`));
|
|
109
|
+
}
|
|
110
|
+
}, 30_000);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
_notify(method, params) {
|
|
115
|
+
this._send({ jsonrpc: "2.0", method, params });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async callTool(toolName, args) {
|
|
119
|
+
const result = await this._request("tools/call", {
|
|
120
|
+
name: toolName,
|
|
121
|
+
arguments: args || {},
|
|
122
|
+
});
|
|
123
|
+
// MCP returns { content: [{type:'text', text:...}], isError? }
|
|
124
|
+
if (result?.content) {
|
|
125
|
+
const text = result.content
|
|
126
|
+
.map((c) => (c.type === "text" ? c.text : JSON.stringify(c)))
|
|
127
|
+
.join("\n");
|
|
128
|
+
return result.isError ? `Error: ${text}` : text;
|
|
129
|
+
}
|
|
130
|
+
return JSON.stringify(result);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
stop() {
|
|
134
|
+
try {
|
|
135
|
+
this.proc?.kill();
|
|
136
|
+
} catch {
|
|
137
|
+
// ignore
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Reads MCP server config from <home>/config.json:
|
|
143
|
+
// { "mcpServers": { "name": { "command": "...", "args": [...], "env": {} } } }
|
|
144
|
+
export function readMcpConfig() {
|
|
145
|
+
try {
|
|
146
|
+
const raw = fs.readFileSync(configFilePath(), "utf8");
|
|
147
|
+
const cfg = JSON.parse(raw);
|
|
148
|
+
return cfg.mcpServers || {};
|
|
149
|
+
} catch {
|
|
150
|
+
return {};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Manages all configured MCP servers and exposes their tools as purrx tool
|
|
155
|
+
// definitions (namespaced as "mcp__<server>__<tool>").
|
|
156
|
+
export class McpManager {
|
|
157
|
+
constructor() {
|
|
158
|
+
this.servers = new Map();
|
|
159
|
+
this.toolRoutes = new Map(); // namespacedName -> { server, toolName }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* @param {(msg: string) => void} [onLog]
|
|
164
|
+
* @returns {Promise<import("../types.js").ToolDefinition[]>}
|
|
165
|
+
*/
|
|
166
|
+
async connectAll(onLog = (/** @type {string} */ _msg) => {}) {
|
|
167
|
+
const config = readMcpConfig();
|
|
168
|
+
const names = Object.keys(config);
|
|
169
|
+
if (!names.length) return [];
|
|
170
|
+
|
|
171
|
+
const defs = [];
|
|
172
|
+
for (const name of names) {
|
|
173
|
+
if (config[name].disabled) continue;
|
|
174
|
+
const server = new McpServer(name, config[name]);
|
|
175
|
+
try {
|
|
176
|
+
const tools = await server.start();
|
|
177
|
+
this.servers.set(name, server);
|
|
178
|
+
for (const t of tools) {
|
|
179
|
+
const nsName = `mcp__${name}__${t.name}`;
|
|
180
|
+
this.toolRoutes.set(nsName, { server, toolName: t.name });
|
|
181
|
+
defs.push({
|
|
182
|
+
type: "function",
|
|
183
|
+
name: nsName,
|
|
184
|
+
description: `[MCP:${name}] ${t.description || t.name}`,
|
|
185
|
+
parameters: t.inputSchema || {
|
|
186
|
+
type: "object",
|
|
187
|
+
properties: {},
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
onLog(`connected MCP server "${name}" (${tools.length} tools)`);
|
|
192
|
+
} catch (err) {
|
|
193
|
+
onLog(`failed to connect MCP server "${name}": ${/** @type {Error} */ (err).message}`);
|
|
194
|
+
server.stop();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return defs;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
isMcpTool(name) {
|
|
201
|
+
return this.toolRoutes.has(name);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async callTool(name, args) {
|
|
205
|
+
const route = this.toolRoutes.get(name);
|
|
206
|
+
if (!route) return `Error: unknown MCP tool "${name}"`;
|
|
207
|
+
try {
|
|
208
|
+
return await route.server.callTool(route.toolName, args);
|
|
209
|
+
} catch (err) {
|
|
210
|
+
return `Error: ${err.message}`;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
stopAll() {
|
|
215
|
+
for (const s of this.servers.values()) s.stop();
|
|
216
|
+
this.servers.clear();
|
|
217
|
+
this.toolRoutes.clear();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
serverCount() {
|
|
221
|
+
return this.servers.size;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { toolDefinitions as builtinTools, executeTool, READ_ONLY_TOOLS } from "./builtin.js";
|
|
2
|
+
import { McpManager } from "./mcp.js";
|
|
3
|
+
|
|
4
|
+
// The registry unifies three kinds of tools:
|
|
5
|
+
// 1. purrx built-in function tools (file ops, search, shell, fetch)
|
|
6
|
+
// 2. OpenAI/ChatGPT server-side tools (e.g. web_search) - executed by the API
|
|
7
|
+
// 3. MCP tools from configured servers - proxied locally
|
|
8
|
+
//
|
|
9
|
+
// It produces the combined `tools` array sent to the API and routes
|
|
10
|
+
// tool calls to the right executor.
|
|
11
|
+
|
|
12
|
+
// Server-side tools the model runs on OpenAI's side. We just declare them;
|
|
13
|
+
// the API handles execution and streams results back as output items.
|
|
14
|
+
export const SERVER_SIDE_TOOLS = {
|
|
15
|
+
web_search: { type: "web_search" },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export class ToolRegistry {
|
|
19
|
+
constructor() {
|
|
20
|
+
this.mcp = new McpManager();
|
|
21
|
+
this.mcpDefs = [];
|
|
22
|
+
this.enabledServerSide = new Set();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Connect MCP servers (if any configured) and collect their tool defs.
|
|
26
|
+
async init({ onLog = () => {}, webSearch = true } = {}) {
|
|
27
|
+
this.mcpDefs = await this.mcp.connectAll(onLog);
|
|
28
|
+
if (webSearch) this.enabledServerSide.add("web_search");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The full tool list to send to the API.
|
|
32
|
+
definitions() {
|
|
33
|
+
const serverSide = [...this.enabledServerSide]
|
|
34
|
+
.map((k) => SERVER_SIDE_TOOLS[k])
|
|
35
|
+
.filter(Boolean);
|
|
36
|
+
return [...builtinTools, ...this.mcpDefs, ...serverSide];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
isReadOnly(name) {
|
|
40
|
+
return READ_ONLY_TOOLS.has(name);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
isMcp(name) {
|
|
44
|
+
return this.mcp.isMcpTool(name);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Execute a locally-handled tool call (built-in or MCP).
|
|
48
|
+
async execute(name, args, cwd) {
|
|
49
|
+
if (this.mcp.isMcpTool(name)) {
|
|
50
|
+
return this.mcp.callTool(name, args);
|
|
51
|
+
}
|
|
52
|
+
return executeTool(name, args, cwd);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
shutdown() {
|
|
56
|
+
this.mcp.stopAll();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
mcpServerCount() {
|
|
60
|
+
return this.mcp.serverCount();
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/types.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Shared JSDoc type definitions for purrx. This file has no runtime exports;
|
|
2
|
+
// it exists so other modules can reference types via `import("./types.js")`.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How requests authenticate against the backend.
|
|
6
|
+
* @typedef {Object} AuthInfo
|
|
7
|
+
* @property {"apikey"|"chatgpt"} mode
|
|
8
|
+
* @property {string} [apiKey] Present when mode is "apikey".
|
|
9
|
+
* @property {string} [accessToken] Present when mode is "chatgpt".
|
|
10
|
+
* @property {string|null} [accountId] ChatGPT account id, if known.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Token bundle stored inside auth.json.
|
|
15
|
+
* @typedef {Object} StoredTokens
|
|
16
|
+
* @property {string} id_token
|
|
17
|
+
* @property {string} access_token
|
|
18
|
+
* @property {string} refresh_token
|
|
19
|
+
* @property {string|null} account_id
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The on-disk auth.json shape (Codex-compatible).
|
|
24
|
+
* @typedef {Object} AuthDotJson
|
|
25
|
+
* @property {string|null} [OPENAI_API_KEY]
|
|
26
|
+
* @property {StoredTokens|null} [tokens]
|
|
27
|
+
* @property {string} [last_refresh]
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A single conversation item in the Responses API input format.
|
|
32
|
+
* @typedef {Object} HistoryItem
|
|
33
|
+
* @property {string} type
|
|
34
|
+
* @property {string} [role]
|
|
35
|
+
* @property {Array<Object>} [content]
|
|
36
|
+
* @property {string} [name]
|
|
37
|
+
* @property {string} [arguments]
|
|
38
|
+
* @property {string} [call_id]
|
|
39
|
+
* @property {string} [output]
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A persisted conversation session.
|
|
44
|
+
* @typedef {Object} Session
|
|
45
|
+
* @property {string} id
|
|
46
|
+
* @property {string} created_at
|
|
47
|
+
* @property {string} updated_at
|
|
48
|
+
* @property {string} cwd
|
|
49
|
+
* @property {string|null} model
|
|
50
|
+
* @property {string} [policy]
|
|
51
|
+
* @property {HistoryItem[]} history
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* An OpenAI Responses API tool definition (function or server-side tool).
|
|
56
|
+
* @typedef {Object} ToolDefinition
|
|
57
|
+
* @property {string} type
|
|
58
|
+
* @property {string} [name]
|
|
59
|
+
* @property {string} [description]
|
|
60
|
+
* @property {Object} [parameters]
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Approval policy names.
|
|
65
|
+
* @typedef {"suggest"|"auto-edit"|"full-auto"} ApprovalPolicy
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
export {};
|
package/src/ui/render.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { marked } from "marked";
|
|
2
|
+
import { markedTerminal } from "marked-terminal";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
|
|
5
|
+
// Configure marked to render Markdown for the terminal (bold, lists, tables,
|
|
6
|
+
// and syntax-highlighted code blocks via cli-highlight, which marked-terminal
|
|
7
|
+
// uses internally).
|
|
8
|
+
let configured = false;
|
|
9
|
+
function ensureConfigured() {
|
|
10
|
+
if (configured) return;
|
|
11
|
+
marked.use(
|
|
12
|
+
markedTerminal({
|
|
13
|
+
// Tasteful, modern palette. marked-terminal accepts chalk functions.
|
|
14
|
+
code: chalk.cyan,
|
|
15
|
+
blockquote: chalk.gray.italic,
|
|
16
|
+
heading: chalk.bold.white,
|
|
17
|
+
firstHeading: chalk.bold.white,
|
|
18
|
+
strong: chalk.bold,
|
|
19
|
+
em: chalk.italic,
|
|
20
|
+
link: chalk.blue.underline,
|
|
21
|
+
href: chalk.blue.underline,
|
|
22
|
+
listitem: (text) => text,
|
|
23
|
+
reflowText: false,
|
|
24
|
+
width: Math.min(process.stdout.columns || 80, 100),
|
|
25
|
+
})
|
|
26
|
+
);
|
|
27
|
+
configured = true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Render a Markdown string to styled terminal text.
|
|
32
|
+
* @param {string} md
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export function renderMarkdown(md) {
|
|
36
|
+
// chalk.level === 0 means colors are disabled (NO_COLOR / non-TTY without
|
|
37
|
+
// FORCE_COLOR). In that case marked-terminal still adds useful structure
|
|
38
|
+
// (indentation, bullets), so we render anyway but it stays plain.
|
|
39
|
+
ensureConfigured();
|
|
40
|
+
try {
|
|
41
|
+
// marked may return a promise if async extensions are used; ours are sync.
|
|
42
|
+
const out = /** @type {string} */ (marked.parse(md));
|
|
43
|
+
return typeof out === "string" ? out.replace(/\n$/, "") : md;
|
|
44
|
+
} catch {
|
|
45
|
+
return md;
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/ui/theme.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import boxen from "boxen";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
|
|
5
|
+
// Terminal styling built on chalk (color), boxen (frames), and ora (spinner).
|
|
6
|
+
// chalk auto-detects color support and respects NO_COLOR, so we don't need a
|
|
7
|
+
// manual on/off switch here.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Color helpers. Thin wrappers over chalk so the rest of the app has a stable
|
|
11
|
+
* surface and we can restyle in one place.
|
|
12
|
+
*/
|
|
13
|
+
export const c = {
|
|
14
|
+
reset: "",
|
|
15
|
+
bold: (s) => chalk.bold(s),
|
|
16
|
+
dim: (s) => chalk.dim(s),
|
|
17
|
+
italic: (s) => chalk.italic(s),
|
|
18
|
+
red: (s) => chalk.red(s),
|
|
19
|
+
green: (s) => chalk.green(s),
|
|
20
|
+
yellow: (s) => chalk.yellow(s),
|
|
21
|
+
blue: (s) => chalk.blue(s),
|
|
22
|
+
magenta: (s) => chalk.magenta(s),
|
|
23
|
+
cyan: (s) => chalk.cyan(s),
|
|
24
|
+
gray: (s) => chalk.gray(s),
|
|
25
|
+
// Brand accent used for the wordmark and prompt.
|
|
26
|
+
accent: (s) => chalk.hex("#a78bfa")(s),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function termWidth() {
|
|
30
|
+
return Math.min(process.stdout.columns || 80, 100);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Horizontal rule, optionally with a left-aligned label.
|
|
35
|
+
* @param {string} [label]
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
export function rule(label = "") {
|
|
39
|
+
const width = termWidth();
|
|
40
|
+
if (!label) return chalk.gray("─".repeat(width));
|
|
41
|
+
// strip ANSI for length math
|
|
42
|
+
const plain = label.replace(/\x1b\[[0-9;]*m/g, "");
|
|
43
|
+
const prefix = "── ";
|
|
44
|
+
const rest = Math.max(0, width - prefix.length - plain.length - 1);
|
|
45
|
+
return chalk.gray(prefix) + label + " " + chalk.gray("─".repeat(rest));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Render content inside a rounded box.
|
|
50
|
+
* @param {string} content
|
|
51
|
+
* @param {{ title?: string, borderColor?: string, dimBorder?: boolean }} [opts]
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
export function box(content, opts = {}) {
|
|
55
|
+
return boxen(content, {
|
|
56
|
+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
57
|
+
borderStyle: "round",
|
|
58
|
+
borderColor: opts.borderColor || "gray",
|
|
59
|
+
dimBorder: opts.dimBorder ?? false,
|
|
60
|
+
title: opts.title,
|
|
61
|
+
width: termWidth(),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Cat-flavored status phrases shown while the agent works. The UX is feline,
|
|
66
|
+
// the visuals stay clean (no ASCII cat).
|
|
67
|
+
const WORKING_PHRASES = [
|
|
68
|
+
"prowling",
|
|
69
|
+
"sniffing around",
|
|
70
|
+
"pawing through the code",
|
|
71
|
+
"on the hunt",
|
|
72
|
+
"stalking the bug",
|
|
73
|
+
"thinking",
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
export function randomWorkingPhrase() {
|
|
77
|
+
return WORKING_PHRASES[Math.floor(Math.random() * WORKING_PHRASES.length)];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Create an ora-backed spinner. Returns a small wrapper with the same surface
|
|
82
|
+
* the rest of the app already uses (start/setText/stop).
|
|
83
|
+
* @param {string} [text]
|
|
84
|
+
*/
|
|
85
|
+
export function createSpinner(text = "thinking") {
|
|
86
|
+
const spinner = ora({
|
|
87
|
+
text: chalk.dim(text),
|
|
88
|
+
spinner: "dots",
|
|
89
|
+
color: "magenta",
|
|
90
|
+
// ora handles non-TTY by silently no-op rendering.
|
|
91
|
+
});
|
|
92
|
+
return {
|
|
93
|
+
start() {
|
|
94
|
+
spinner.start();
|
|
95
|
+
},
|
|
96
|
+
setText(t) {
|
|
97
|
+
spinner.text = chalk.dim(t);
|
|
98
|
+
},
|
|
99
|
+
stop() {
|
|
100
|
+
spinner.stop();
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Symbols used across the UI. Clean and modern; the cat personality lives in
|
|
106
|
+
// the wording, not in ASCII faces.
|
|
107
|
+
export const sym = {
|
|
108
|
+
ok: chalk.green("✓"),
|
|
109
|
+
fail: chalk.red("✗"),
|
|
110
|
+
bullet: chalk.gray("·"),
|
|
111
|
+
arrow: chalk.hex("#a78bfa")("❯"),
|
|
112
|
+
tool: chalk.blue("⚙"),
|
|
113
|
+
warn: chalk.yellow("!"),
|
|
114
|
+
};
|