opencode-auto-agent 1.1.0 → 1.2.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/src/lib/ui.js ADDED
@@ -0,0 +1,446 @@
1
+ /**
2
+ * Terminal UI utilities — zero-dep, inspired by OpenCode's TUI aesthetic.
3
+ *
4
+ * Provides box-drawing, ANSI colors, spinners, and interactive prompts
5
+ * that work on Node 18+ with raw stdin.
6
+ */
7
+
8
+ import { createInterface } from "node:readline";
9
+ import { stdin, stdout } from "node:process";
10
+
11
+ // ── ANSI color helpers ─────────────────────────────────────────────────────
12
+
13
+ const ESC = "\x1b[";
14
+
15
+ export const c = {
16
+ reset: (s) => `${ESC}0m${s}${ESC}0m`,
17
+ bold: (s) => `${ESC}1m${s}${ESC}0m`,
18
+ dim: (s) => `${ESC}2m${s}${ESC}0m`,
19
+ italic: (s) => `${ESC}3m${s}${ESC}0m`,
20
+ underline:(s) => `${ESC}4m${s}${ESC}0m`,
21
+
22
+ // Theme colors (OpenCode-inspired)
23
+ cyan: (s) => `${ESC}36m${s}${ESC}0m`,
24
+ green: (s) => `${ESC}32m${s}${ESC}0m`,
25
+ yellow: (s) => `${ESC}33m${s}${ESC}0m`,
26
+ red: (s) => `${ESC}31m${s}${ESC}0m`,
27
+ magenta: (s) => `${ESC}35m${s}${ESC}0m`,
28
+ blue: (s) => `${ESC}34m${s}${ESC}0m`,
29
+ white: (s) => `${ESC}37m${s}${ESC}0m`,
30
+ gray: (s) => `${ESC}90m${s}${ESC}0m`,
31
+
32
+ // Bright variants
33
+ brightCyan: (s) => `${ESC}96m${s}${ESC}0m`,
34
+ brightGreen: (s) => `${ESC}92m${s}${ESC}0m`,
35
+ brightYellow: (s) => `${ESC}93m${s}${ESC}0m`,
36
+ brightRed: (s) => `${ESC}91m${s}${ESC}0m`,
37
+ brightMagenta: (s) => `${ESC}95m${s}${ESC}0m`,
38
+
39
+ // Background
40
+ bgCyan: (s) => `${ESC}46m${ESC}30m${s}${ESC}0m`,
41
+ bgGreen: (s) => `${ESC}42m${ESC}30m${s}${ESC}0m`,
42
+ bgYellow: (s) => `${ESC}43m${ESC}30m${s}${ESC}0m`,
43
+ bgRed: (s) => `${ESC}41m${ESC}37m${s}${ESC}0m`,
44
+ bgBlue: (s) => `${ESC}44m${ESC}37m${s}${ESC}0m`,
45
+ bgMagenta: (s) => `${ESC}45m${ESC}37m${s}${ESC}0m`,
46
+ };
47
+
48
+ // ── Box-drawing characters ─────────────────────────────────────────────────
49
+
50
+ const BOX = {
51
+ tl: "\u256d", tr: "\u256e", bl: "\u2570", br: "\u256f",
52
+ h: "\u2500", v: "\u2502",
53
+ cross: "\u253c",
54
+ tee_r: "\u251c", tee_l: "\u2524",
55
+ };
56
+
57
+ // ── Layout helpers ─────────────────────────────────────────────────────────
58
+
59
+ /**
60
+ * Get terminal width, fallback to 80.
61
+ */
62
+ function termWidth() {
63
+ return stdout.columns || 80;
64
+ }
65
+
66
+ /**
67
+ * Draw a horizontal rule.
68
+ */
69
+ export function hr(char = BOX.h, width) {
70
+ const w = width || termWidth();
71
+ console.log(c.gray(char.repeat(w)));
72
+ }
73
+
74
+ /**
75
+ * Print a styled header banner (OpenCode-style).
76
+ */
77
+ export function banner(title, subtitle) {
78
+ const w = Math.min(termWidth(), 72);
79
+ const pad = (s, len) => {
80
+ const stripped = stripAnsi(s);
81
+ return s + " ".repeat(Math.max(0, len - stripped.length));
82
+ };
83
+
84
+ console.log();
85
+ console.log(c.cyan(` ${BOX.tl}${BOX.h.repeat(w - 4)}${BOX.tr}`));
86
+ console.log(c.cyan(` ${BOX.v}`) + " " + c.bold(c.brightCyan(pad(title, w - 6))) + c.cyan(BOX.v));
87
+ if (subtitle) {
88
+ console.log(c.cyan(` ${BOX.v}`) + " " + c.gray(pad(subtitle, w - 6)) + c.cyan(BOX.v));
89
+ }
90
+ console.log(c.cyan(` ${BOX.bl}${BOX.h.repeat(w - 4)}${BOX.br}`));
91
+ console.log();
92
+ }
93
+
94
+ /**
95
+ * Print a section header.
96
+ */
97
+ export function section(title) {
98
+ console.log();
99
+ console.log(` ${c.bold(c.cyan(title))}`);
100
+ console.log(` ${c.gray(BOX.h.repeat(stripAnsi(title).length + 2))}`);
101
+ }
102
+
103
+ /**
104
+ * Print a status line with icon.
105
+ */
106
+ export function status(icon, label, detail) {
107
+ const icons = {
108
+ pass: c.green("\u2713"),
109
+ fail: c.red("\u2717"),
110
+ warn: c.yellow("\u26A0"),
111
+ info: c.cyan("\u2139"),
112
+ arrow: c.cyan("\u25B6"),
113
+ dot: c.gray("\u2022"),
114
+ star: c.yellow("\u2605"),
115
+ };
116
+ const i = icons[icon] || icon;
117
+ const d = detail ? c.gray(` ${detail}`) : "";
118
+ console.log(` ${i} ${label}${d}`);
119
+ }
120
+
121
+ /**
122
+ * Print a key-value pair.
123
+ */
124
+ export function kv(key, value) {
125
+ console.log(` ${c.gray(key + ":")} ${c.white(String(value))}`);
126
+ }
127
+
128
+ /**
129
+ * Print an error block.
130
+ */
131
+ export function error(message, hint) {
132
+ console.log();
133
+ console.log(` ${c.bgRed(" ERROR ")} ${c.red(message)}`);
134
+ if (hint) {
135
+ console.log(` ${c.gray(hint)}`);
136
+ }
137
+ console.log();
138
+ }
139
+
140
+ /**
141
+ * Print a warning block.
142
+ */
143
+ export function warn(message, hint) {
144
+ console.log(` ${c.bgYellow(" WARN ")} ${c.yellow(message)}`);
145
+ if (hint) {
146
+ console.log(` ${c.gray(hint)}`);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Print a success block.
152
+ */
153
+ export function success(message) {
154
+ console.log();
155
+ console.log(` ${c.bgGreen(" OK ")} ${c.green(message)}`);
156
+ console.log();
157
+ }
158
+
159
+ /**
160
+ * Print a boxed list of items.
161
+ */
162
+ export function boxList(title, items) {
163
+ const w = Math.min(termWidth(), 68);
164
+ console.log();
165
+ console.log(c.gray(` ${BOX.tl}${BOX.h} ${c.bold(c.white(title))} ${c.gray(BOX.h.repeat(Math.max(0, w - stripAnsi(title).length - 6)))}${c.gray(BOX.tr)}`));
166
+ for (const item of items) {
167
+ const line = ` ${c.gray(BOX.v)} ${item}`;
168
+ console.log(line + " ".repeat(Math.max(0, w - stripAnsi(line).length + 2)) + c.gray(BOX.v));
169
+ }
170
+ console.log(c.gray(` ${BOX.bl}${BOX.h.repeat(w - 2)}${BOX.br}`));
171
+ }
172
+
173
+ // ── Spinner ────────────────────────────────────────────────────────────────
174
+
175
+ const SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
176
+
177
+ export function createSpinner(text) {
178
+ let i = 0;
179
+ let interval = null;
180
+
181
+ return {
182
+ start() {
183
+ interval = setInterval(() => {
184
+ const frame = c.cyan(SPINNER_FRAMES[i % SPINNER_FRAMES.length]);
185
+ stdout.write(`\r ${frame} ${text}`);
186
+ i++;
187
+ }, 80);
188
+ },
189
+ stop(finalText) {
190
+ if (interval) clearInterval(interval);
191
+ stdout.write(`\r ${c.green("\u2713")} ${finalText || text}\n`);
192
+ },
193
+ fail(finalText) {
194
+ if (interval) clearInterval(interval);
195
+ stdout.write(`\r ${c.red("\u2717")} ${finalText || text}\n`);
196
+ },
197
+ };
198
+ }
199
+
200
+ // ── Interactive prompts (zero-dep) ─────────────────────────────────────────
201
+
202
+ /**
203
+ * Ask a yes/no confirmation.
204
+ */
205
+ export async function confirm(question, defaultYes = true) {
206
+ const rl = createInterface({ input: stdin, output: stdout });
207
+ const hint = defaultYes ? "Y/n" : "y/N";
208
+ return new Promise((resolve) => {
209
+ rl.question(` ${c.cyan("?")} ${question} ${c.gray(`(${hint})`)} `, (answer) => {
210
+ rl.close();
211
+ const a = answer.trim().toLowerCase();
212
+ if (a === "") resolve(defaultYes);
213
+ else resolve(a === "y" || a === "yes");
214
+ });
215
+ });
216
+ }
217
+
218
+ /**
219
+ * Ask for text input.
220
+ */
221
+ export async function input(question, defaultValue = "") {
222
+ const rl = createInterface({ input: stdin, output: stdout });
223
+ const hint = defaultValue ? c.gray(` (${defaultValue})`) : "";
224
+ return new Promise((resolve) => {
225
+ rl.question(` ${c.cyan("?")} ${question}${hint} `, (answer) => {
226
+ rl.close();
227
+ resolve(answer.trim() || defaultValue);
228
+ });
229
+ });
230
+ }
231
+
232
+ /**
233
+ * Interactive single-select menu with arrow-key navigation.
234
+ * Returns the selected item object.
235
+ *
236
+ * @param {string} title - Prompt title
237
+ * @param {Array<{label: string, value: any, hint?: string}>} items
238
+ * @returns {Promise<{label: string, value: any}>}
239
+ */
240
+ export async function select(title, items) {
241
+ if (!items.length) throw new Error("select: no items provided");
242
+
243
+ return new Promise((resolve) => {
244
+ let cursor = 0;
245
+
246
+ const render = () => {
247
+ // Move cursor up to overwrite previous render
248
+ if (cursor !== -1) {
249
+ stdout.write(`\x1b[${items.length + 1}A`);
250
+ }
251
+
252
+ stdout.write(` ${c.cyan("?")} ${c.bold(title)}\n`);
253
+ for (let i = 0; i < items.length; i++) {
254
+ const item = items[i];
255
+ const selected = i === cursor;
256
+ const pointer = selected ? c.cyan("\u25B6") : " ";
257
+ const label = selected ? c.cyan(item.label) : c.white(item.label);
258
+ const hint = item.hint ? c.gray(` ${item.hint}`) : "";
259
+ stdout.write(` ${pointer} ${label}${hint}\n`);
260
+ }
261
+ };
262
+
263
+ // Initial render
264
+ stdout.write(` ${c.cyan("?")} ${c.bold(title)}\n`);
265
+ for (let i = 0; i < items.length; i++) {
266
+ const item = items[i];
267
+ const selected = i === cursor;
268
+ const pointer = selected ? c.cyan("\u25B6") : " ";
269
+ const label = selected ? c.cyan(item.label) : c.white(item.label);
270
+ const hint = item.hint ? c.gray(` ${item.hint}`) : "";
271
+ stdout.write(` ${pointer} ${label}${hint}\n`);
272
+ }
273
+
274
+ stdin.setRawMode(true);
275
+ stdin.resume();
276
+ stdin.setEncoding("utf8");
277
+
278
+ const onData = (key) => {
279
+ // Ctrl+C
280
+ if (key === "\x03") {
281
+ stdin.setRawMode(false);
282
+ stdin.removeListener("data", onData);
283
+ stdin.pause();
284
+ process.exit(130);
285
+ }
286
+ // Enter
287
+ if (key === "\r" || key === "\n") {
288
+ stdin.setRawMode(false);
289
+ stdin.removeListener("data", onData);
290
+ stdin.pause();
291
+ // Clear and reprint final selection
292
+ stdout.write(`\x1b[${items.length + 1}A`);
293
+ stdout.write(` ${c.green("\u2713")} ${c.bold(title)} ${c.cyan(items[cursor].label)}\n`);
294
+ for (let i = 0; i < items.length; i++) {
295
+ stdout.write(`\x1b[2K\n`); // clear each line
296
+ }
297
+ stdout.write(`\x1b[${items.length}A`); // move back up
298
+ resolve(items[cursor]);
299
+ return;
300
+ }
301
+ // Arrow up / k
302
+ if (key === "\x1b[A" || key === "k") {
303
+ cursor = (cursor - 1 + items.length) % items.length;
304
+ render();
305
+ }
306
+ // Arrow down / j
307
+ if (key === "\x1b[B" || key === "j") {
308
+ cursor = (cursor + 1) % items.length;
309
+ render();
310
+ }
311
+ };
312
+
313
+ stdin.on("data", onData);
314
+ });
315
+ }
316
+
317
+ /**
318
+ * Interactive multi-select menu with space to toggle and arrow keys to navigate.
319
+ * Returns array of selected item objects.
320
+ *
321
+ * @param {string} title - Prompt title
322
+ * @param {Array<{label: string, value: any, hint?: string, selected?: boolean}>} items
323
+ * @returns {Promise<Array<{label: string, value: any}>>}
324
+ */
325
+ export async function multiSelect(title, items) {
326
+ if (!items.length) throw new Error("multiSelect: no items provided");
327
+
328
+ return new Promise((resolve) => {
329
+ let cursor = 0;
330
+ const selected = new Set(
331
+ items.filter((it) => it.selected).map((_, i) => i)
332
+ );
333
+
334
+ const render = () => {
335
+ stdout.write(`\x1b[${items.length + 2}A`);
336
+ stdout.write(` ${c.cyan("?")} ${c.bold(title)} ${c.gray("(space to toggle, enter to confirm)")}\n`);
337
+ for (let i = 0; i < items.length; i++) {
338
+ const item = items[i];
339
+ const isCursor = i === cursor;
340
+ const isSelected = selected.has(i);
341
+ const pointer = isCursor ? c.cyan("\u25B6") : " ";
342
+ const check = isSelected ? c.green("\u25C9") : c.gray("\u25CB");
343
+ const label = isCursor ? c.cyan(item.label) : c.white(item.label);
344
+ const hint = item.hint ? c.gray(` ${item.hint}`) : "";
345
+ stdout.write(`\x1b[2K ${pointer} ${check} ${label}${hint}\n`);
346
+ }
347
+ stdout.write("\x1b[2K\n");
348
+ };
349
+
350
+ // Initial render
351
+ stdout.write(` ${c.cyan("?")} ${c.bold(title)} ${c.gray("(space to toggle, enter to confirm)")}\n`);
352
+ for (let i = 0; i < items.length; i++) {
353
+ const item = items[i];
354
+ const isCursor = i === cursor;
355
+ const isSelected = selected.has(i);
356
+ const pointer = isCursor ? c.cyan("\u25B6") : " ";
357
+ const check = isSelected ? c.green("\u25C9") : c.gray("\u25CB");
358
+ const label = isCursor ? c.cyan(item.label) : c.white(item.label);
359
+ const hint = item.hint ? c.gray(` ${item.hint}`) : "";
360
+ stdout.write(` ${pointer} ${check} ${label}${hint}\n`);
361
+ }
362
+ stdout.write("\n");
363
+
364
+ stdin.setRawMode(true);
365
+ stdin.resume();
366
+ stdin.setEncoding("utf8");
367
+
368
+ const onData = (key) => {
369
+ if (key === "\x03") {
370
+ stdin.setRawMode(false);
371
+ stdin.removeListener("data", onData);
372
+ stdin.pause();
373
+ process.exit(130);
374
+ }
375
+ if (key === "\r" || key === "\n") {
376
+ stdin.setRawMode(false);
377
+ stdin.removeListener("data", onData);
378
+ stdin.pause();
379
+ const result = [...selected].map((i) => items[i]);
380
+ // Clear and show final
381
+ stdout.write(`\x1b[${items.length + 2}A`);
382
+ stdout.write(`\x1b[2K ${c.green("\u2713")} ${c.bold(title)} ${c.cyan(result.map((r) => r.label).join(", "))}\n`);
383
+ for (let i = 0; i < items.length + 1; i++) {
384
+ stdout.write(`\x1b[2K\n`);
385
+ }
386
+ stdout.write(`\x1b[${items.length + 1}A`);
387
+ resolve(result);
388
+ return;
389
+ }
390
+ if (key === " ") {
391
+ if (selected.has(cursor)) selected.delete(cursor);
392
+ else selected.add(cursor);
393
+ render();
394
+ }
395
+ if (key === "\x1b[A" || key === "k") {
396
+ cursor = (cursor - 1 + items.length) % items.length;
397
+ render();
398
+ }
399
+ if (key === "\x1b[B" || key === "j") {
400
+ cursor = (cursor + 1) % items.length;
401
+ render();
402
+ }
403
+ };
404
+
405
+ stdin.on("data", onData);
406
+ });
407
+ }
408
+
409
+ /**
410
+ * Display a table with columns.
411
+ * @param {string[]} headers
412
+ * @param {string[][]} rows
413
+ */
414
+ export function table(headers, rows) {
415
+ const colWidths = headers.map((h, i) => {
416
+ const maxData = rows.reduce((max, row) => Math.max(max, stripAnsi(String(row[i] || "")).length), 0);
417
+ return Math.max(stripAnsi(h).length, maxData) + 2;
418
+ });
419
+
420
+ const headerLine = headers.map((h, i) => c.bold(h.padEnd(colWidths[i]))).join(c.gray(" \u2502 "));
421
+ console.log(` ${headerLine}`);
422
+ console.log(` ${colWidths.map((w) => c.gray(BOX.h.repeat(w))).join(c.gray("\u2500\u253c\u2500"))}`);
423
+
424
+ for (const row of rows) {
425
+ const line = row.map((cell, i) => String(cell || "").padEnd(colWidths[i])).join(c.gray(" \u2502 "));
426
+ console.log(` ${line}`);
427
+ }
428
+ }
429
+
430
+ // ── Utilities ──────────────────────────────────────────────────────────────
431
+
432
+ /**
433
+ * Strip ANSI escape codes from a string.
434
+ */
435
+ export function stripAnsi(str) {
436
+ // eslint-disable-next-line no-control-regex
437
+ return str.replace(/\x1b\[[0-9;]*m/g, "");
438
+ }
439
+
440
+ /**
441
+ * Truncate a string to maxLen, adding ellipsis if needed.
442
+ */
443
+ export function truncate(str, maxLen = 40) {
444
+ if (str.length <= maxLen) return str;
445
+ return str.slice(0, maxLen - 1) + "\u2026";
446
+ }
@@ -1,19 +1,58 @@
1
1
  {
2
- "$schema": "https://github.com/ai-starter-kit/config-schema",
3
- "version": "0.1.0",
2
+ "$schema": "https://github.com/opencode-auto-agent/config-schema",
3
+ "version": "0.2.0",
4
4
  "preset": "java",
5
5
  "docsPath": "./Docs",
6
6
  "tasksPath": "./PRD.md",
7
7
  "outputPath": "./",
8
8
  "engine": "opencode",
9
+ "models": {
10
+ "orchestrator": "google/gemini-3-pro",
11
+ "planner": "google/gemini-3-pro",
12
+ "developer": "openai/gpt-5.1-codex",
13
+ "qa": "anthropic/claude-sonnet-4-5",
14
+ "reviewer": "anthropic/claude-sonnet-4-5",
15
+ "docs": "anthropic/claude-sonnet-4-5"
16
+ },
9
17
  "agents": {
10
- "orchestrator": { "enabled": true },
11
- "planner": { "enabled": true },
12
- "developer": { "enabled": true },
13
- "qa": { "enabled": true },
14
- "reviewer": { "enabled": true },
15
- "docs": { "enabled": true }
18
+ "orchestrator": {
19
+ "enabled": true,
20
+ "model": "google/gemini-3-pro",
21
+ "mode": "primary"
22
+ },
23
+ "planner": {
24
+ "enabled": true,
25
+ "model": "google/gemini-3-pro",
26
+ "mode": "subagent"
27
+ },
28
+ "developer": {
29
+ "enabled": true,
30
+ "model": "openai/gpt-5.1-codex",
31
+ "mode": "subagent"
32
+ },
33
+ "qa": {
34
+ "enabled": true,
35
+ "model": "anthropic/claude-sonnet-4-5",
36
+ "mode": "subagent"
37
+ },
38
+ "reviewer": {
39
+ "enabled": true,
40
+ "model": "anthropic/claude-sonnet-4-5",
41
+ "mode": "subagent"
42
+ },
43
+ "docs": {
44
+ "enabled": true,
45
+ "model": "anthropic/claude-sonnet-4-5",
46
+ "mode": "subagent"
47
+ }
16
48
  },
49
+ "rules": [
50
+ ".opencode/rules/universal.md"
51
+ ],
52
+ "instructions": [
53
+ "AGENTS.md",
54
+ ".opencode/rules/universal.md"
55
+ ],
17
56
  "orchestrator": {
18
57
  "workflowSteps": ["plan", "implement", "test", "verify"],
19
58
  "requirePlanApproval": false