@oxecli/oxe 1.0.0

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/dist/ui.js ADDED
@@ -0,0 +1,569 @@
1
+ import * as readline from "node:readline";
2
+ // ---------------------------------------------------------------------------
3
+ // Terminal helpers
4
+ // ---------------------------------------------------------------------------
5
+ export function isWindows() {
6
+ return process.platform === "win32";
7
+ }
8
+ export function enableAnsi() {
9
+ // Node's tty on Windows Terminal / modern conhost supports ANSI automatically.
10
+ // No native SetConsoleMode available without a native module; rely on the host.
11
+ }
12
+ export function clearScreen() {
13
+ if (isWindows()) {
14
+ // crude but reliable clear
15
+ process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
16
+ }
17
+ else {
18
+ process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
19
+ }
20
+ }
21
+ export function terminalWidth() {
22
+ return process.stdout.columns || 80;
23
+ }
24
+ // ---------------------------------------------------------------------------
25
+ // Markup tag -> ANSI
26
+ // ---------------------------------------------------------------------------
27
+ const TAG_MAP = {
28
+ bold: "\x1b[1m",
29
+ dim: "\x1b[2m",
30
+ italic: "\x1b[3m",
31
+ underline: "\x1b[4m",
32
+ red: "\x1b[31m",
33
+ green: "\x1b[32m",
34
+ yellow: "\x1b[33m",
35
+ blue: "\x1b[34m",
36
+ magenta: "\x1b[35m",
37
+ cyan: "\x1b[36m",
38
+ white: "\x1b[37m",
39
+ };
40
+ const RESET = "\x1b[0m";
41
+ const MARKUP_RE = /\[(\/?)([a-z0-9 ]+)\]/gi;
42
+ export function escapeMarkup(text) {
43
+ return text.replace(/\[/g, "\\[").replace(/\]/g, "\\]");
44
+ }
45
+ export function markupToAnsi(text) {
46
+ let open = false;
47
+ return text.replace(MARKUP_RE, (m, slash, tag) => {
48
+ const tags = tag.trim().split(/\s+/);
49
+ if (slash) {
50
+ if (open) {
51
+ open = false;
52
+ return RESET;
53
+ }
54
+ return "";
55
+ }
56
+ let codes = "";
57
+ let known = false;
58
+ for (const t of tags) {
59
+ const code = TAG_MAP[t];
60
+ if (code) {
61
+ codes += code;
62
+ known = true;
63
+ }
64
+ }
65
+ if (!known)
66
+ return m;
67
+ open = true;
68
+ return codes;
69
+ });
70
+ }
71
+ // ---------------------------------------------------------------------------
72
+ // Lightweight markdown -> ANSI
73
+ // ---------------------------------------------------------------------------
74
+ export function markdownToAnsi(text) {
75
+ const lines = text.split("\n");
76
+ const out = [];
77
+ let inCode = false;
78
+ let codeBuf = [];
79
+ const flushCode = () => {
80
+ if (codeBuf.length) {
81
+ out.push(`\x1b[2m${codeBuf.join("\n")}\x1b[0m`);
82
+ codeBuf = [];
83
+ }
84
+ };
85
+ for (const raw of lines) {
86
+ const line = raw;
87
+ const fence = line.match(/^(`{3,})/);
88
+ if (fence) {
89
+ if (!inCode) {
90
+ inCode = true;
91
+ continue;
92
+ }
93
+ else {
94
+ inCode = false;
95
+ flushCode();
96
+ continue;
97
+ }
98
+ }
99
+ if (inCode) {
100
+ codeBuf.push(line);
101
+ continue;
102
+ }
103
+ let s = line;
104
+ // inline code
105
+ s = s.replace(/`([^`]+)`/g, (_, c) => `\x1b[2m${c}\x1b[0m`);
106
+ // bold
107
+ s = s.replace(/\*\*([^*]+)\*\*/g, "\x1b[1m$1\x1b[0m");
108
+ // italic
109
+ s = s.replace(/(^|[^\w*])\*([^*\n]+)\*(?=$|[^\w*])/g, "$1\x1b[3m$2\x1b[0m");
110
+ // headers
111
+ const h = s.match(/^(#{1,6})\s+(.*)$/);
112
+ if (h) {
113
+ s = `\x1b[1m${h[2]}\x1b[0m`;
114
+ }
115
+ // links
116
+ s = s.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
117
+ // leading list markers
118
+ s = s.replace(/^(\s*)([-*+]\s)/, "$1· ");
119
+ out.push(s);
120
+ }
121
+ flushCode();
122
+ return out.join("\n");
123
+ }
124
+ export function aiMarkdown(text) {
125
+ return markdownToAnsi(text);
126
+ }
127
+ export function mutedMarkdown(text) {
128
+ const dimmed = text
129
+ .split("\n")
130
+ .map((l) => `\x1b[2m${l}\x1b[0m`)
131
+ .join("\n");
132
+ return markdownToAnsi(dimmed);
133
+ }
134
+ // ---------------------------------------------------------------------------
135
+ // Panel
136
+ // ---------------------------------------------------------------------------
137
+ export function renderPanel(content, title = "") {
138
+ const w = terminalWidth();
139
+ const innerW = Math.max(w - 4, 10);
140
+ const styled = markupToAnsi(content);
141
+ const plainLines = styled.replace(/\x1b\[[0-9;]*m/g, "").split("\n");
142
+ const border = "─".repeat(innerW);
143
+ console.log(`\x1b[90m+${border}+`);
144
+ if (title) {
145
+ const titleStr = ` ${title} `;
146
+ const pad = Math.max(innerW - titleStr.length, 0);
147
+ console.log(`\x1b[90m|${titleStr}${" ".repeat(pad)}|`);
148
+ }
149
+ const styledLines = styled.split("\n");
150
+ for (let i = 0; i < styledLines.length; i++) {
151
+ const line = styledLines[i];
152
+ const plain = plainLines[i] ?? "";
153
+ const pad = Math.max(innerW - plain.length, 0);
154
+ console.log(`\x1b[90m| ${line}${" ".repeat(pad)} |\x1b[0m`);
155
+ }
156
+ console.log(`\x1b[90m+${border}+\x1b[0m`);
157
+ }
158
+ // ---------------------------------------------------------------------------
159
+ // Durations
160
+ // ---------------------------------------------------------------------------
161
+ export function formatDuration(seconds) {
162
+ const total = Math.max(0, Math.round(seconds));
163
+ const hours = Math.floor(total / 3600);
164
+ const rem = total % 3600;
165
+ const minutes = Math.floor(rem / 60);
166
+ const secs = rem % 60;
167
+ const parts = [];
168
+ if (hours)
169
+ parts.push(`${hours}h`);
170
+ if (minutes)
171
+ parts.push(`${minutes}min`);
172
+ if (secs || !parts.length)
173
+ parts.push(`${secs}s`);
174
+ return parts.join(" ");
175
+ }
176
+ export function tickDuration(seconds) {
177
+ return formatDuration(seconds).replace(/(\d+[a-z]*)/g, "`$1`");
178
+ }
179
+ // ---------------------------------------------------------------------------
180
+ // Text helpers
181
+ // ---------------------------------------------------------------------------
182
+ const FENCE_MARKER_RE = /`{3,}/g;
183
+ export function printAiChunk(chunk) {
184
+ process.stdout.write(markdownToAnsi(chunk) + "\n");
185
+ if (/^```/m.test(chunk))
186
+ process.stdout.write("\n");
187
+ }
188
+ export function safeCommitPoint(text) {
189
+ let idx = text.lastIndexOf("\n\n");
190
+ while (idx !== -1) {
191
+ const fenceCount = (text.slice(0, idx).match(FENCE_MARKER_RE) || []).length;
192
+ if (fenceCount % 2 === 0)
193
+ return idx;
194
+ idx = text.lastIndexOf("\n\n", idx - 1);
195
+ }
196
+ return 0;
197
+ }
198
+ export function truncateEllipsis(text, maxChars, label = "text") {
199
+ if (text.length > maxChars) {
200
+ return text.slice(0, maxChars) + ` …(${label} truncated: ${text.length.toLocaleString()} chars total)`;
201
+ }
202
+ return text;
203
+ }
204
+ export function displayRows(text) {
205
+ if (!text)
206
+ return 1;
207
+ const plain = text.replace(/`/g, "");
208
+ const width = Math.max(terminalWidth() || 80, 1);
209
+ let rows = 0;
210
+ const lines = plain.split("\n");
211
+ if (!lines.length)
212
+ return 1;
213
+ for (const line of lines) {
214
+ rows += Math.max(1, Math.ceil(line.length / width));
215
+ }
216
+ return rows;
217
+ }
218
+ // ---------------------------------------------------------------------------
219
+ // Tool action formatter
220
+ // ---------------------------------------------------------------------------
221
+ export function formatToolAction(name, argumentsJson, status = "ok") {
222
+ let args = {};
223
+ try {
224
+ args = argumentsJson ? JSON.parse(argumentsJson) : {};
225
+ }
226
+ catch {
227
+ args = {};
228
+ }
229
+ const phrase = (verbOk, verbIng, verbFail, detail) => {
230
+ if (status === "started")
231
+ return `${verbIng} ${detail}`;
232
+ if (status === "failed")
233
+ return `${verbFail} ${detail}`;
234
+ return `${verbOk} ${detail}`;
235
+ };
236
+ const p = args["path"] ?? "";
237
+ switch (name) {
238
+ case "read_file": {
239
+ const start = args["start_line"];
240
+ const end = args["end_line"];
241
+ const span = start != null || end != null
242
+ ? `, lines ${start ?? 1} to ${end ?? "end"}`
243
+ : "";
244
+ return phrase("Read", "Reading", "Failed to read", `${p}${span}`);
245
+ }
246
+ case "write_file":
247
+ return phrase("Wrote", "Writing", "Failed to write", `${p} (${String(args["content"] ?? "").length} chars)`);
248
+ case "edit_file": {
249
+ const suffix = args["replace_all"] ? " (replace all)" : "";
250
+ return phrase("Edited", "Editing", "Failed to edit", `${p}${suffix}`);
251
+ }
252
+ case "bash": {
253
+ const cwd = args["cwd"];
254
+ const location = cwd ? ` in ${cwd}` : "";
255
+ const command = truncateEllipsis(String(args["command"] ?? ""), 400, "command");
256
+ if (status === "started")
257
+ return `Running command${location}: ${command}`;
258
+ if (status === "failed")
259
+ return `Command failed${location}: ${command}`;
260
+ return `Ran command${location}: ${command}`;
261
+ }
262
+ case "glob": {
263
+ const detail = String(args["pattern"] ?? "");
264
+ if (status === "failed")
265
+ return `Search failed for ${detail}`;
266
+ return phrase("Searched for", "Searching for", "", detail);
267
+ }
268
+ case "grep": {
269
+ const detail = String(args["pattern"] ?? "");
270
+ if (status === "failed")
271
+ return `Grep failed for ${detail}`;
272
+ return phrase("Grepped for", "Grepping for", "", detail);
273
+ }
274
+ case "load_skill": {
275
+ const skill = String(args["skill_name"] ?? "");
276
+ return phrase("Loaded skill", "Loading skill", "Failed to load skill", skill);
277
+ }
278
+ default:
279
+ return phrase("Invoked internal routine", "Invoking internal routine", "Internal routine failed", name);
280
+ }
281
+ }
282
+ // ---------------------------------------------------------------------------
283
+ // Prompt editing / interactive input
284
+ // ---------------------------------------------------------------------------
285
+ export const PROMPT_PLACEHOLDER = "Describe a coding task, or type /help to see available commands";
286
+ const MAX_PROMPT_DISPLAY_LINES = 12;
287
+ const MAX_PROMPT_PASTE_CHARS = 400;
288
+ function shouldCollapsePaste(text) {
289
+ return (text.split("\n").length > MAX_PROMPT_DISPLAY_LINES ||
290
+ text.length > MAX_PROMPT_PASTE_CHARS);
291
+ }
292
+ export function cursorLineCol(buffer, cursor) {
293
+ const before = buffer.slice(0, cursor);
294
+ const lines = before.split("\n");
295
+ return [lines.length - 1, lines[lines.length - 1].length];
296
+ }
297
+ function endsWithWs(s) {
298
+ return s.length > 0 && /[ \t\n]/.test(s[s.length - 1]);
299
+ }
300
+ export function splitBlocks(buffer, pasteSpans) {
301
+ if (!buffer)
302
+ return [["", "text", ""]];
303
+ const pts = new Set([0, buffer.length]);
304
+ for (const [s, e] of pasteSpans) {
305
+ pts.add(s);
306
+ pts.add(e);
307
+ }
308
+ const sorted = [...pts].sort((a, b) => a - b);
309
+ const segs = [];
310
+ const isPaste = (a, b) => pasteSpans.some(([s, e]) => s <= a && b <= e);
311
+ for (let i = 0; i < sorted.length - 1; i++) {
312
+ const a = sorted[i];
313
+ const b = sorted[i + 1];
314
+ if (a === b)
315
+ continue;
316
+ const text = buffer.slice(a, b);
317
+ if (isPaste(a, b) && shouldCollapsePaste(text)) {
318
+ const n = text.split("\n").length;
319
+ segs.push(["", "collapsed", `[Pasted text, ${n} lines]`]);
320
+ }
321
+ else {
322
+ segs.push(["", "text", text]);
323
+ }
324
+ }
325
+ return segs;
326
+ }
327
+ function renderBufferWithCursor(buffer, pasteSpans, prefix, cursor) {
328
+ const [row, col] = cursorLineCol(buffer, cursor);
329
+ const segs = splitBlocks(buffer, pasteSpans);
330
+ let display = "";
331
+ for (let i = 0; i < segs.length; i++) {
332
+ const [, kind, disp] = segs[i];
333
+ if (i && !endsWithWs(segs[i - 1][2]))
334
+ display += " ";
335
+ display += kind === "collapsed" ? `\x1b[1m\x1b[36m${disp}\x1b[0m` : disp;
336
+ }
337
+ const lines = display.split("\n");
338
+ const prefixStr = `\x1b[1m${prefix}\x1b[0m `;
339
+ // Reconstruct lines, prepending prefix to first line
340
+ const rendered = [prefixStr + (lines[0] ?? "")];
341
+ for (let i = 1; i < lines.length; i++)
342
+ rendered.push(lines[i] ?? "");
343
+ // Place cursor on the correct line/col (prefix+1 offset on line 0)
344
+ let cursorRow = row;
345
+ let cursorCol = col + (row === 0 ? prefix.length + 1 : 0);
346
+ const result = rendered.join("\n");
347
+ const currentLines = result.split("\n").length;
348
+ // Move cursor up currentLines-1 then right cursorCol
349
+ let cmd = "";
350
+ if (currentLines > 1)
351
+ cmd += `\x1b[${currentLines - 1}A`;
352
+ cmd += `\r\x1b[${cursorCol}C`;
353
+ return result + cmd;
354
+ }
355
+ export function askBottomPrompt(label = "You", prefix = "❯", history = []) {
356
+ return new Promise((resolve, reject) => {
357
+ const isTTY = process.stdin.isTTY;
358
+ if (!isTTY) {
359
+ // Non-interactive: read a line from stdin
360
+ const chunks = [];
361
+ process.stdin.setEncoding("utf-8");
362
+ process.stdin.on("data", (d) => chunks.push(Buffer.from(String(d))));
363
+ process.stdin.on("end", () => {
364
+ const text = Buffer.concat(chunks).toString();
365
+ resolve([text.trim(), []]);
366
+ });
367
+ return;
368
+ }
369
+ let buffer = "";
370
+ let cursor = 0;
371
+ let pasteSpans = [];
372
+ const hist = history.slice();
373
+ let histIdx = hist.length;
374
+ let draft = null;
375
+ readline.emitKeypressEvents(process.stdin);
376
+ if (process.stdin.isTTY)
377
+ process.stdin.setRawMode(true);
378
+ process.stdin.resume();
379
+ let done = false;
380
+ const finish = (resolveVal) => {
381
+ if (done)
382
+ return;
383
+ done = true;
384
+ process.stdin.setRawMode(false);
385
+ process.stdout.write("\r\x1b[K");
386
+ process.stdout.write("\n");
387
+ resolve(resolveVal);
388
+ };
389
+ const repaint = () => {
390
+ const frame = renderBufferWithCursor(buffer, pasteSpans, prefix, cursor);
391
+ process.stdout.write("\r\x1b[K");
392
+ process.stdout.write(frame);
393
+ };
394
+ const insert = (text) => {
395
+ if (histIdx !== hist.length) {
396
+ draft = { buffer, cursor, spans: pasteSpans.slice() };
397
+ histIdx = hist.length;
398
+ }
399
+ buffer = buffer.slice(0, cursor) + text + buffer.slice(cursor);
400
+ cursor += text.length;
401
+ };
402
+ const backspace = () => {
403
+ if (cursor <= 0)
404
+ return;
405
+ if (histIdx !== hist.length) {
406
+ draft = { buffer, cursor, spans: pasteSpans.slice() };
407
+ histIdx = hist.length;
408
+ }
409
+ // remove one grapheme before cursor
410
+ const before = Array.from(buffer.slice(0, cursor));
411
+ before.pop();
412
+ buffer = before.join("") + buffer.slice(cursor);
413
+ cursor -= 1;
414
+ };
415
+ const moveLeft = () => {
416
+ if (cursor > 0)
417
+ cursor -= 1;
418
+ };
419
+ const moveRight = () => {
420
+ if (cursor < buffer.length)
421
+ cursor += 1;
422
+ };
423
+ const moveUp = () => {
424
+ if (!buffer.includes("\n")) {
425
+ if (!hist.length)
426
+ return;
427
+ if (histIdx === hist.length) {
428
+ draft = { buffer, cursor, spans: pasteSpans.slice() };
429
+ }
430
+ if (histIdx > 0) {
431
+ histIdx -= 1;
432
+ buffer = hist[histIdx];
433
+ cursor = buffer.length;
434
+ pasteSpans = [];
435
+ }
436
+ }
437
+ };
438
+ const moveDown = () => {
439
+ if (!buffer.includes("\n")) {
440
+ if (histIdx === hist.length || !draft)
441
+ return;
442
+ histIdx += 1;
443
+ if (histIdx === hist.length) {
444
+ buffer = draft.buffer;
445
+ cursor = draft.cursor;
446
+ pasteSpans = draft.spans.slice();
447
+ }
448
+ else {
449
+ buffer = hist[histIdx];
450
+ cursor = buffer.length;
451
+ pasteSpans = [];
452
+ }
453
+ }
454
+ };
455
+ const onKeypress = (str, key) => {
456
+ if (key && key.ctrl && key.name === "c") {
457
+ finish(["", []]);
458
+ reject(new Error("interrupt"));
459
+ return;
460
+ }
461
+ if (key && key.ctrl && (key.name === "d" || key.name === "z")) {
462
+ finish([buffer, []]);
463
+ reject(new Error("eof"));
464
+ return;
465
+ }
466
+ if (key && key.name === "return") {
467
+ if (buffer.trim()) {
468
+ finish([buffer, pasteSpans]);
469
+ }
470
+ return;
471
+ }
472
+ if (key && key.name === "backspace") {
473
+ backspace();
474
+ repaint();
475
+ return;
476
+ }
477
+ if (key && key.name === "left") {
478
+ moveLeft();
479
+ repaint();
480
+ return;
481
+ }
482
+ if (key && key.name === "right") {
483
+ moveRight();
484
+ repaint();
485
+ return;
486
+ }
487
+ if (key && key.name === "up") {
488
+ moveUp();
489
+ repaint();
490
+ return;
491
+ }
492
+ if (key && key.name === "down") {
493
+ moveDown();
494
+ repaint();
495
+ return;
496
+ }
497
+ if (key && key.name === "tab") {
498
+ insert(" ");
499
+ repaint();
500
+ return;
501
+ }
502
+ if (key && key.name === "space") {
503
+ insert(" ");
504
+ repaint();
505
+ return;
506
+ }
507
+ if (str) {
508
+ // paste / multi-char sequence
509
+ insert(str);
510
+ repaint();
511
+ }
512
+ };
513
+ process.stdin.on("keypress", onKeypress);
514
+ repaint();
515
+ });
516
+ }
517
+ // ---------------------------------------------------------------------------
518
+ // User display + message text
519
+ // ---------------------------------------------------------------------------
520
+ export function userDisplayText(payload, pasteSpans) {
521
+ if (pasteSpans && pasteSpans.length) {
522
+ const segs = splitBlocks(payload, pasteSpans);
523
+ let out = "";
524
+ for (let i = 0; i < segs.length; i++) {
525
+ const [, kind, disp] = segs[i];
526
+ if (i && !endsWithWs(segs[i - 1][2]))
527
+ out += " ";
528
+ out += kind === "collapsed" ? `\x1b[1m\x1b[36m${disp}\x1b[0m` : disp;
529
+ }
530
+ return out;
531
+ }
532
+ if (shouldCollapsePaste(payload)) {
533
+ return `\x1b[1m\x1b[36m[Pasted text, ${payload.split("\n").length} lines]\x1b[0m`;
534
+ }
535
+ return payload;
536
+ }
537
+ export function collapseLabelText(text, spans) {
538
+ if (spans && spans.length) {
539
+ const segs = splitBlocks(text, spans);
540
+ const parts = [];
541
+ for (const [, kind, disp] of segs) {
542
+ if (kind === "collapsed") {
543
+ parts.push(disp.replace(/\[[^\]]+\]/g, "").trim());
544
+ }
545
+ else {
546
+ parts.push(disp);
547
+ }
548
+ }
549
+ return parts.filter(Boolean).join(" ");
550
+ }
551
+ if (shouldCollapsePaste(text)) {
552
+ return `[Pasted text, ${text.split("\n").length} lines]`;
553
+ }
554
+ return text;
555
+ }
556
+ export function messageText(item) {
557
+ const content = item["content"];
558
+ if (typeof content === "string")
559
+ return content;
560
+ if (Array.isArray(content)) {
561
+ const parts = [];
562
+ for (const part of content) {
563
+ if (part && typeof part === "object" && part["text"])
564
+ parts.push(part["text"]);
565
+ }
566
+ return parts.join("\n");
567
+ }
568
+ return "";
569
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@oxecli/oxe",
3
+ "version": "1.0.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "Oxe - a terminal-based AI coding agent (native TypeScript CLI).",
8
+ "keywords": [
9
+ "ai",
10
+ "coding-agent",
11
+ "cli",
12
+ "terminal",
13
+ "coding",
14
+ "agent",
15
+ "oxe"
16
+ ],
17
+ "license": "MIT",
18
+ "type": "module",
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "bin": {
23
+ "oxe": "bin/oxe.js"
24
+ },
25
+ "main": "dist/oxe.js",
26
+ "files": [
27
+ "bin/",
28
+ "dist/",
29
+ "skills/",
30
+ ".env",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "scripts": {
35
+ "build": "tsc -p tsconfig.json",
36
+ "prepare": "npm run build",
37
+ "start": "node dist/oxe.js"
38
+ },
39
+ "dependencies": {
40
+ "diff": "^5.2.0",
41
+ "openai": "^4.0.0",
42
+ "yaml": "^2.0.0"
43
+ },
44
+ "devDependencies": {
45
+ "@types/diff": "^7.0.2",
46
+ "@types/node": "^20.0.0",
47
+ "typescript": "^5.0.0"
48
+ }
49
+ }