@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/src/ui/tui.js ADDED
@@ -0,0 +1,317 @@
1
+ import chalk from "chalk";
2
+ import { input, select } from "@inquirer/prompts";
3
+ import { runTurn } from "../core/agent.js";
4
+ import { createApprovalManager } from "../core/approval.js";
5
+ import { saveSession, createSession } from "../core/session.js";
6
+ import { listModels } from "../api/models.js";
7
+ import { ToolRegistry } from "../tools/registry.js";
8
+ import { listSkills, loadMemories } from "../core/context.js";
9
+ import { estimateTokens, maybeCompact } from "../core/compact.js";
10
+ import { CONTEXT_LIMIT } from "../config.js";
11
+ import { c, sym, box } from "./theme.js";
12
+
13
+ const ACCENT = "#a78bfa";
14
+
15
+ /**
16
+ * Start the interactive TUI session.
17
+ * @param {import("../types.js").AuthInfo} authInfo
18
+ * @param {Object} [options]
19
+ */
20
+ export async function startTui(authInfo, options = {}) {
21
+ const cwd = process.cwd();
22
+
23
+ const session =
24
+ options.session || createSession({ cwd, model: options.model });
25
+ if (options.model) session.model = options.model;
26
+ const history = session.history;
27
+
28
+ const state = {
29
+ approval: createApprovalManager(options.policy || "suggest"),
30
+ };
31
+
32
+ const registry = new ToolRegistry();
33
+ await registry.init({
34
+ onLog: (msg) => console.log(c.dim(` ${sym.bullet} ${msg}`)),
35
+ webSearch: options.webSearch !== false,
36
+ });
37
+
38
+ const persist = () => {
39
+ try {
40
+ saveSession(session);
41
+ } catch (err) {
42
+ if (process.env.PURRX_DEBUG) console.error(`save failed: ${err.message}`);
43
+ }
44
+ };
45
+
46
+ // Approval prompt powered by inquirer select (arrow-key menu) for a nicer UX
47
+ // than typed y/n. We override the manager's prompt path.
48
+ state.approval.setPrompter(async (toolName, description) => {
49
+ const choice = await select({
50
+ message: `${chalk.yellow("approve")} ${description}`,
51
+ choices: [
52
+ { name: "yes, once", value: "approve" },
53
+ { name: `always allow ${toolName} this session`, value: "always" },
54
+ { name: "no, skip it", value: "reject" },
55
+ ],
56
+ });
57
+ return choice;
58
+ });
59
+
60
+ printHeader(authInfo, session, state.approval, registry, cwd);
61
+
62
+ // Main input loop.
63
+ // eslint-disable-next-line no-constant-condition
64
+ while (true) {
65
+ let line;
66
+ try {
67
+ line = await input({
68
+ message: chalk.hex(ACCENT)("❯"),
69
+ theme: {
70
+ prefix: "",
71
+ style: { answer: (s) => chalk.white(s) },
72
+ },
73
+ });
74
+ } catch (err) {
75
+ // Ctrl+C / Ctrl+D throw an ExitPromptError.
76
+ break;
77
+ }
78
+
79
+ const text = (line || "").trim();
80
+ if (!text) continue;
81
+
82
+ if (text.startsWith("/")) {
83
+ const result = await handleCommand(text, {
84
+ session,
85
+ authInfo,
86
+ state,
87
+ registry,
88
+ cwd,
89
+ });
90
+ if (result === "close") break;
91
+ continue;
92
+ }
93
+
94
+ try {
95
+ await runTurn({
96
+ authInfo,
97
+ history,
98
+ userMessage: text,
99
+ cwd,
100
+ model: session.model,
101
+ registry,
102
+ approval: state.approval,
103
+ onChange: persist,
104
+ });
105
+ } catch (err) {
106
+ console.log(box(chalk.red(err.message), { title: "error", borderColor: "red" }));
107
+ }
108
+ console.log();
109
+ }
110
+
111
+ persist();
112
+ registry.shutdown();
113
+ console.log(c.dim(`\nsession saved: ${session.id}`));
114
+ console.log(c.dim("the cat curls up. see you next time."));
115
+ process.exit(0);
116
+ }
117
+
118
+ function printHeader(authInfo, session, approval, registry, cwd) {
119
+ const wordmark = chalk.hex(ACCENT).bold("purrx");
120
+ const tagline = chalk.dim("your terminal's coding companion");
121
+ const mode = authInfo.mode === "chatgpt" ? "ChatGPT account" : "API key";
122
+ const skills = listSkills(cwd).length;
123
+ const memories = loadMemories().length;
124
+
125
+ const lines = [
126
+ `${wordmark} ${tagline}`,
127
+ "",
128
+ `${c.dim("model")} ${session.model}`,
129
+ `${c.dim("auth")} ${mode}`,
130
+ `${c.dim("approval")} ${approval.policy}`,
131
+ `${c.dim("context")} ${registry.mcpServerCount()} mcp · ${skills} skills · ${memories} memories`,
132
+ ];
133
+ console.log(box(lines.join("\n"), { borderColor: "magenta" }));
134
+ console.log(
135
+ c.dim(" /help for commands · /exit to quit · Ctrl+C to interrupt\n")
136
+ );
137
+ }
138
+
139
+ async function handleCommand(text, ctx) {
140
+ const [cmd, ...args] = text.slice(1).split(/\s+/);
141
+ switch (cmd) {
142
+ case "exit":
143
+ case "quit":
144
+ return "close";
145
+
146
+ case "help":
147
+ console.log(
148
+ box(
149
+ [
150
+ `${c.accent("/help")} show this help`,
151
+ `${c.accent("/model")} [id] show or pick the model`,
152
+ `${c.accent("/models")} list models your account can use`,
153
+ `${c.accent("/approval")} pick approval policy`,
154
+ `${c.accent("/tools")} list available tools`,
155
+ `${c.accent("/skills")} list available skills`,
156
+ `${c.accent("/memory")} show stored memories`,
157
+ `${c.accent("/context")} show context-window usage`,
158
+ `${c.accent("/compact")} summarize older history now`,
159
+ `${c.accent("/reset")} clear conversation history`,
160
+ `${c.accent("/session")} show session info`,
161
+ `${c.accent("/exit")} quit`,
162
+ ].join("\n"),
163
+ { title: "commands", borderColor: "gray" }
164
+ )
165
+ );
166
+ break;
167
+
168
+ case "model":
169
+ if (args[0]) {
170
+ ctx.session.model = args[0];
171
+ console.log(c.dim(`model set to ${args[0]}`));
172
+ } else {
173
+ const models = await listModels(ctx.authInfo).catch(() => []);
174
+ if (!models.length) {
175
+ console.log(c.dim("could not retrieve models."));
176
+ break;
177
+ }
178
+ const picked = await select({
179
+ message: "pick a model",
180
+ choices: models.map((m) => ({ name: m, value: m })),
181
+ default: ctx.session.model,
182
+ }).catch(() => null);
183
+ if (picked) {
184
+ ctx.session.model = picked;
185
+ console.log(c.dim(`model set to ${picked}`));
186
+ }
187
+ }
188
+ break;
189
+
190
+ case "models": {
191
+ const models = await listModels(ctx.authInfo).catch(() => []);
192
+ if (!models.length) {
193
+ console.log(c.dim("could not retrieve models."));
194
+ break;
195
+ }
196
+ console.log(
197
+ models
198
+ .map((m) => ` ${m === ctx.session.model ? sym.ok : sym.bullet} ${m}`)
199
+ .join("\n")
200
+ );
201
+ break;
202
+ }
203
+
204
+ case "approval": {
205
+ const picked = await select({
206
+ message: "approval policy",
207
+ choices: [
208
+ { name: "suggest (ask before writes & commands)", value: "suggest" },
209
+ { name: "auto-edit (auto files, ask for commands)", value: "auto-edit" },
210
+ { name: "full-auto (never ask)", value: "full-auto" },
211
+ ],
212
+ default: ctx.state.approval.policy,
213
+ }).catch(() => null);
214
+ if (picked) {
215
+ const prompter = ctx.state.approval.getPrompter?.();
216
+ ctx.state.approval = createApprovalManager(picked);
217
+ if (prompter) ctx.state.approval.setPrompter(prompter);
218
+ ctx.session.policy = picked;
219
+ console.log(c.dim(`approval policy set to ${picked}`));
220
+ }
221
+ break;
222
+ }
223
+
224
+ case "tools": {
225
+ const defs = ctx.registry.definitions();
226
+ console.log(c.dim(`${defs.length} tools available:`));
227
+ for (const d of defs) {
228
+ const name = d.name || d.type;
229
+ const desc = d.description
230
+ ? c.dim(` ${d.description.slice(0, 56)}`)
231
+ : "";
232
+ console.log(` ${sym.tool} ${name}${desc}`);
233
+ }
234
+ break;
235
+ }
236
+
237
+ case "skills": {
238
+ const skills = listSkills(ctx.cwd);
239
+ if (!skills.length) {
240
+ console.log(c.dim("no skills found. add SKILL.md files to enable them."));
241
+ break;
242
+ }
243
+ for (const s of skills) {
244
+ console.log(` ${c.accent(s.name)} ${c.dim(s.description || "")}`);
245
+ }
246
+ break;
247
+ }
248
+
249
+ case "memory": {
250
+ const mems = loadMemories();
251
+ if (!mems.length) {
252
+ console.log(c.dim("no memories yet. the agent saves them with the remember tool."));
253
+ break;
254
+ }
255
+ for (const m of mems) {
256
+ console.log(box(m.content, { title: m.name, borderColor: "gray" }));
257
+ }
258
+ break;
259
+ }
260
+
261
+ case "reset":
262
+ ctx.session.history.length = 0;
263
+ console.log(c.dim("history cleared."));
264
+ break;
265
+
266
+ case "compact": {
267
+ const before = ctx.session.history.length;
268
+ const did = await maybeCompact({
269
+ authInfo: ctx.authInfo,
270
+ history: ctx.session.history,
271
+ model: ctx.session.model,
272
+ usedTokens: CONTEXT_LIMIT, // force compaction regardless of size
273
+ onInfo: (msg) => console.log(c.dim(` ${sym.bullet} ${msg}`)),
274
+ }).catch((err) => {
275
+ console.log(c.dim(`compaction failed: ${err.message}`));
276
+ return false;
277
+ });
278
+ if (!did) {
279
+ console.log(c.dim("nothing to compact yet."));
280
+ } else {
281
+ console.log(
282
+ c.dim(`compacted ${before} -> ${ctx.session.history.length} items.`)
283
+ );
284
+ }
285
+ break;
286
+ }
287
+
288
+ case "context": {
289
+ const est = estimateTokens(ctx.session.history);
290
+ const pct = Math.min(100, Math.round((est / CONTEXT_LIMIT) * 100));
291
+ console.log(
292
+ c.dim(
293
+ `~${est.toLocaleString()} / ${CONTEXT_LIMIT.toLocaleString()} tokens (${pct}%) · ${ctx.session.history.length} items`
294
+ )
295
+ );
296
+ break;
297
+ }
298
+
299
+ case "session":
300
+ console.log(
301
+ box(
302
+ [
303
+ `id ${ctx.session.id}`,
304
+ `created ${ctx.session.created_at}`,
305
+ `cwd ${ctx.session.cwd}`,
306
+ `messages ${ctx.session.history.length} items`,
307
+ ].join("\n"),
308
+ { title: "session", borderColor: "gray" }
309
+ )
310
+ );
311
+ break;
312
+
313
+ default:
314
+ console.log(c.dim(`unknown command: /${cmd} (try /help)`));
315
+ }
316
+ return "ok";
317
+ }