roger-roger 0.1.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/LICENSE +21 -0
- package/README.md +147 -0
- package/package.json +45 -0
- package/skills/roger-roger/SKILL.md +289 -0
- package/skills/roger-roger/herdr-plugin.toml +38 -0
- package/skills/roger-roger/scripts/agent.mjs +132 -0
- package/skills/roger-roger/scripts/audio.mjs +392 -0
- package/skills/roger-roger/scripts/client.mjs +121 -0
- package/skills/roger-roger/scripts/daemon.mjs +604 -0
- package/skills/roger-roger/scripts/decisions.mjs +158 -0
- package/skills/roger-roger/scripts/handlers.mjs +1151 -0
- package/skills/roger-roger/scripts/herdr.mjs +140 -0
- package/skills/roger-roger/scripts/hooks-codex.mjs +154 -0
- package/skills/roger-roger/scripts/hooks-opencode.mjs +167 -0
- package/skills/roger-roger/scripts/hooks.mjs +420 -0
- package/skills/roger-roger/scripts/inbox.mjs +381 -0
- package/skills/roger-roger/scripts/install.mjs +560 -0
- package/skills/roger-roger/scripts/lib.mjs +1133 -0
- package/skills/roger-roger/scripts/names.mjs +84 -0
- package/skills/roger-roger/scripts/progress.mjs +91 -0
- package/skills/roger-roger/scripts/protocol.mjs +71 -0
- package/skills/roger-roger/scripts/roger-roger.mjs +536 -0
- package/skills/roger-roger/scripts/router.mjs +86 -0
- package/skills/roger-roger/scripts/sessions.mjs +218 -0
- package/skills/roger-roger/scripts/slack.mjs +240 -0
- package/skills/roger-roger/scripts/slackapp.mjs +205 -0
- package/skills/roger-roger/scripts/slackcli.mjs +144 -0
- package/skills/roger-roger/scripts/speaker.mjs +224 -0
- package/skills/roger-roger/scripts/speechkey.mjs +106 -0
- package/skills/roger-roger/scripts/tray.mjs +128 -0
- package/skills/roger-roger/scripts/tts.mjs +275 -0
- package/skills/roger-roger/scripts/tui.mjs +465 -0
- package/skills/roger-roger/slack/manifest.json +34 -0
- package/skills/roger-roger/sounds/alert.wav +0 -0
- package/skills/roger-roger/sounds/bubble.wav +0 -0
- package/skills/roger-roger/sounds/chime.wav +0 -0
- package/skills/roger-roger/sounds/ding.wav +0 -0
- package/skills/roger-roger/sounds/marimba.wav +0 -0
- package/skills/roger-roger/tray/main.mjs +749 -0
- package/skills/roger-roger/tray/panel.html +501 -0
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
// A small terminal UI for `install`: a select, a multi-select, a confirm, a text field, a step with a
|
|
2
|
+
// spinner, and notes. Raw mode and ANSI escapes, nothing else, so the skill keeps its
|
|
3
|
+
// no-dependencies promise. Every prompt reads from `input` and draws on `output`, so a test can
|
|
4
|
+
// drive one with a fake stream and read back exactly what it drew.
|
|
5
|
+
//
|
|
6
|
+
// The look is deliberate: one column of rail characters down the left (◆ for the question being
|
|
7
|
+
// asked, ◇ for one answered, │ between, └ at the foot), so the whole setup reads as one page rather
|
|
8
|
+
// than a stack of prompts.
|
|
9
|
+
|
|
10
|
+
import { EOL } from "node:os";
|
|
11
|
+
|
|
12
|
+
export class Cancelled extends Error {
|
|
13
|
+
constructor() {
|
|
14
|
+
super("cancelled");
|
|
15
|
+
this.name = "Cancelled";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------- colours and characters
|
|
20
|
+
|
|
21
|
+
/** Colour on or off: the user's say (NO_COLOR, FORCE_COLOR) first, then whether this is a terminal. */
|
|
22
|
+
export function coloursOn(output = process.stdout, env = process.env) {
|
|
23
|
+
if (env.NO_COLOR !== undefined && env.NO_COLOR !== "") return false;
|
|
24
|
+
if (env.FORCE_COLOR !== undefined) return env.FORCE_COLOR !== "0";
|
|
25
|
+
return Boolean(output.isTTY) && env.TERM !== "dumb";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function makeStyle(on) {
|
|
29
|
+
const wrap = (open, close) => (s) => (on ? `\x1b[${open}m${s}\x1b[${close}m` : String(s));
|
|
30
|
+
return {
|
|
31
|
+
on,
|
|
32
|
+
bold: wrap(1, 22),
|
|
33
|
+
dim: wrap(2, 22),
|
|
34
|
+
italic: wrap(3, 23),
|
|
35
|
+
underline: wrap(4, 24),
|
|
36
|
+
inverse: wrap(7, 27),
|
|
37
|
+
red: wrap(31, 39),
|
|
38
|
+
green: wrap(32, 39),
|
|
39
|
+
yellow: wrap(33, 39),
|
|
40
|
+
blue: wrap(34, 39),
|
|
41
|
+
magenta: wrap(35, 39),
|
|
42
|
+
cyan: wrap(36, 39),
|
|
43
|
+
gray: wrap(90, 39),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const strip = (s) => String(s).replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "");
|
|
48
|
+
|
|
49
|
+
/** Visible width, near enough: escapes don't count, and wide characters (emoji, CJK) count double. */
|
|
50
|
+
export function width(s) {
|
|
51
|
+
let n = 0;
|
|
52
|
+
for (const ch of strip(s)) {
|
|
53
|
+
const code = ch.codePointAt(0);
|
|
54
|
+
if (code < 32 || (code >= 0x300 && code <= 0x36f) || code === 0xfe0f || code === 0x200d) continue;
|
|
55
|
+
const wide =
|
|
56
|
+
(code >= 0x1100 && code <= 0x115f) ||
|
|
57
|
+
(code >= 0x2e80 && code <= 0xa4cf) ||
|
|
58
|
+
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
59
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
60
|
+
(code >= 0xfe30 && code <= 0xfe4f) ||
|
|
61
|
+
(code >= 0xff00 && code <= 0xff60) ||
|
|
62
|
+
(code >= 0xffe0 && code <= 0xffe6) ||
|
|
63
|
+
(code >= 0x1f300 && code <= 0x1faff) ||
|
|
64
|
+
(code >= 0x20000 && code <= 0x3fffd);
|
|
65
|
+
n += wide ? 2 : 1;
|
|
66
|
+
}
|
|
67
|
+
return n;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Cut a plain string to `max` columns, with an ellipsis if anything was cut. */
|
|
71
|
+
export function fit(text, max) {
|
|
72
|
+
const s = String(text);
|
|
73
|
+
if (max <= 0) return "";
|
|
74
|
+
if (width(s) <= max) return s;
|
|
75
|
+
let out = "";
|
|
76
|
+
for (const ch of s) {
|
|
77
|
+
if (width(out + ch) > max - 1) break;
|
|
78
|
+
out += ch;
|
|
79
|
+
}
|
|
80
|
+
return out + "…";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const RAIL = { ask: "◆", done: "◇", bar: "│", end: "└", fail: "■", radioOn: "●", radioOff: "○", boxOn: "◼", boxOff: "◻" };
|
|
84
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------- keys
|
|
87
|
+
|
|
88
|
+
/** One chunk from a raw-mode terminal → which key it was. Pasted text arrives as one `char` holding all of it. */
|
|
89
|
+
export function parseKey(data) {
|
|
90
|
+
const s = data.toString("utf8");
|
|
91
|
+
if (s === "\r" || s === "\n" || s === "\r\n") return { name: "enter" };
|
|
92
|
+
if (s === "\x03") return { name: "ctrl-c" };
|
|
93
|
+
if (s === "\x04") return { name: "ctrl-d" };
|
|
94
|
+
if (s === "\x1b") return { name: "escape" };
|
|
95
|
+
if (s === "\x7f" || s === "\b") return { name: "backspace" };
|
|
96
|
+
if (s === "\t") return { name: "tab" };
|
|
97
|
+
if (s === " ") return { name: "space" };
|
|
98
|
+
if (s === "\x1b[A" || s === "\x1bOA") return { name: "up" };
|
|
99
|
+
if (s === "\x1b[B" || s === "\x1bOB") return { name: "down" };
|
|
100
|
+
if (s === "\x1b[C" || s === "\x1bOC") return { name: "right" };
|
|
101
|
+
if (s === "\x1b[D" || s === "\x1bOD") return { name: "left" };
|
|
102
|
+
if (s === "\x1b[H" || s === "\x1b[1~" || s === "\x1bOH") return { name: "home" };
|
|
103
|
+
if (s === "\x1b[F" || s === "\x1b[4~" || s === "\x1bOF") return { name: "end" };
|
|
104
|
+
if (s === "\x1b[3~") return { name: "delete" };
|
|
105
|
+
if (s.startsWith("\x1b")) return { name: "unknown", raw: s };
|
|
106
|
+
// Control characters in a paste are dropped; a newline in one ends the line, as Enter would.
|
|
107
|
+
const text = s.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
|
|
108
|
+
if (/[\r\n]/.test(text)) return { name: "char", ch: text.split(/[\r\n]/)[0], enter: true };
|
|
109
|
+
return { name: "char", ch: text };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Keys from `input`, one at a time, for as long as the prompt wants them. */
|
|
113
|
+
function keyReader(input) {
|
|
114
|
+
const queue = [];
|
|
115
|
+
const waiters = [];
|
|
116
|
+
const onData = (d) => {
|
|
117
|
+
const key = parseKey(d);
|
|
118
|
+
const waiter = waiters.shift();
|
|
119
|
+
if (waiter) waiter(key);
|
|
120
|
+
else queue.push(key);
|
|
121
|
+
};
|
|
122
|
+
const onEnd = () => onData(Buffer.from("\x04"));
|
|
123
|
+
input.on("data", onData);
|
|
124
|
+
input.on("end", onEnd);
|
|
125
|
+
return {
|
|
126
|
+
next: () => new Promise((resolve) => (queue.length ? resolve(queue.shift()) : waiters.push(resolve))),
|
|
127
|
+
close: () => {
|
|
128
|
+
input.off("data", onData);
|
|
129
|
+
input.off("end", onEnd);
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------- the UI
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A UI bound to one input and one output. `interactive` is false when either is not a terminal;
|
|
138
|
+
* the prompts then throw, and the caller is expected to have taken the non-interactive path.
|
|
139
|
+
*/
|
|
140
|
+
export function createUI({ input = process.stdin, output = process.stdout, env = process.env, colours = coloursOn(output, env) } = {}) {
|
|
141
|
+
const c = makeStyle(colours);
|
|
142
|
+
const tty = Boolean(input.isTTY && output.isTTY);
|
|
143
|
+
const columns = () => Math.max(40, output.columns || 80);
|
|
144
|
+
const write = (s) => output.write(s);
|
|
145
|
+
const nl = EOL;
|
|
146
|
+
|
|
147
|
+
const rail = {
|
|
148
|
+
ask: c.cyan(RAIL.ask),
|
|
149
|
+
done: c.green(RAIL.done),
|
|
150
|
+
fail: c.red(RAIL.fail),
|
|
151
|
+
bar: c.gray(RAIL.bar),
|
|
152
|
+
end: c.gray(RAIL.end),
|
|
153
|
+
};
|
|
154
|
+
const line = (text = "") => `${rail.bar} ${text}`;
|
|
155
|
+
const cancelKey = (key) => key.name === "ctrl-c" || key.name === "escape" || key.name === "ctrl-d";
|
|
156
|
+
const isEnter = (key) => key.name === "enter" || (key.name === "char" && key.enter);
|
|
157
|
+
|
|
158
|
+
// Raw mode is on only while a prompt is reading, so a spinner or a subprocess in between sees the
|
|
159
|
+
// terminal as the user left it.
|
|
160
|
+
function rawOn() {
|
|
161
|
+
if (typeof input.setRawMode === "function") input.setRawMode(true);
|
|
162
|
+
input.resume();
|
|
163
|
+
}
|
|
164
|
+
function rawOff() {
|
|
165
|
+
if (typeof input.setRawMode === "function") input.setRawMode(false);
|
|
166
|
+
input.pause();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A styled line cut to the terminal width, without cutting inside an escape sequence. */
|
|
170
|
+
function fitStyled(styled, max) {
|
|
171
|
+
if (width(styled) <= max) return styled;
|
|
172
|
+
let out = "";
|
|
173
|
+
let seen = 0;
|
|
174
|
+
const re = /(\x1b\[[0-9;?]*[A-Za-z])|([\s\S])/g;
|
|
175
|
+
for (let m; (m = re.exec(styled)); ) {
|
|
176
|
+
if (m[1]) {
|
|
177
|
+
out += m[1];
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (seen + width(m[2]) > max - 1) break;
|
|
181
|
+
out += m[2];
|
|
182
|
+
seen += width(m[2]);
|
|
183
|
+
}
|
|
184
|
+
return out + "…" + (c.on ? "\x1b[0m" : "");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Redraw a block of lines in place: up over what was drawn last time, clear, draw again. */
|
|
188
|
+
function painter() {
|
|
189
|
+
let drawn = 0;
|
|
190
|
+
return {
|
|
191
|
+
draw(lines) {
|
|
192
|
+
let s = "";
|
|
193
|
+
if (drawn) s += `\x1b[${drawn}A`;
|
|
194
|
+
s += "\x1b[0J";
|
|
195
|
+
const max = columns() - 1;
|
|
196
|
+
s += lines.map((l) => fitStyled(l, max)).join(nl) + nl;
|
|
197
|
+
write(s);
|
|
198
|
+
drawn = lines.length;
|
|
199
|
+
},
|
|
200
|
+
clear() {
|
|
201
|
+
if (drawn) write(`\x1b[${drawn}A\x1b[0J`);
|
|
202
|
+
drawn = 0;
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The loop every prompt shares: raw mode on, keys in, redraw, and the terminal put back after. */
|
|
208
|
+
async function prompt(draw, onKey) {
|
|
209
|
+
if (!tty) throw new Error("this needs a terminal; pass --yes for a setup without questions");
|
|
210
|
+
const paint = painter();
|
|
211
|
+
write("\x1b[?25l");
|
|
212
|
+
rawOn();
|
|
213
|
+
const keys = keyReader(input);
|
|
214
|
+
try {
|
|
215
|
+
paint.draw(draw());
|
|
216
|
+
for (;;) {
|
|
217
|
+
const key = await keys.next();
|
|
218
|
+
if (cancelKey(key)) throw new Cancelled();
|
|
219
|
+
const result = await onKey(key, () => paint.draw(draw()));
|
|
220
|
+
if (result === true) break;
|
|
221
|
+
paint.draw(draw());
|
|
222
|
+
}
|
|
223
|
+
} finally {
|
|
224
|
+
keys.close();
|
|
225
|
+
rawOff();
|
|
226
|
+
paint.clear();
|
|
227
|
+
write("\x1b[?25h");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The line a finished prompt leaves behind: the question, and the answer under it. */
|
|
232
|
+
const answered = (message, answer) => write(`${rail.done} ${message}` + nl + line(c.dim(answer)) + nl + line() + nl);
|
|
233
|
+
|
|
234
|
+
const ui = {
|
|
235
|
+
style: c,
|
|
236
|
+
interactive: tty,
|
|
237
|
+
rail,
|
|
238
|
+
columns,
|
|
239
|
+
write,
|
|
240
|
+
|
|
241
|
+
/** The opening: a name and a line under it. */
|
|
242
|
+
intro(title, subtitle = "") {
|
|
243
|
+
write(nl + `${rail.bar} ${c.bold(title)}` + (subtitle ? nl + line(c.dim(subtitle)) : "") + nl + line() + nl);
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
/** A plain line on the rail. */
|
|
247
|
+
log(text = "") {
|
|
248
|
+
write(line(text) + nl);
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
/** A step that needed no question, closed in one line. `detail` may be several lines. */
|
|
252
|
+
done(title, detail = "") {
|
|
253
|
+
const details = [].concat(detail).filter(Boolean);
|
|
254
|
+
write(`${rail.done} ${title}` + details.map((d) => nl + line(c.dim(d))).join("") + nl + line() + nl);
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
failed(title, detail = "") {
|
|
258
|
+
write(`${rail.fail} ${c.red(title)}` + (detail ? nl + line(c.dim(detail)) : "") + nl + line() + nl);
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
/** A boxed note: something to read, or to copy. */
|
|
262
|
+
note(title, lines = []) {
|
|
263
|
+
const inner = Math.min(columns() - 8, Math.max(width(title), ...lines.map(width)) + 2);
|
|
264
|
+
const pad = (s) => fit(s, inner) + " ".repeat(Math.max(0, inner - width(fit(s, inner))));
|
|
265
|
+
const edge = c.gray("│");
|
|
266
|
+
write(line(c.gray(`┌${"─".repeat(inner + 2)}┐`)) + nl);
|
|
267
|
+
write(line(`${edge} ${c.bold(pad(title))} ${edge}`) + nl);
|
|
268
|
+
if (lines.length) write(line(`${edge} ${pad("")} ${edge}`) + nl);
|
|
269
|
+
for (const l of lines) write(line(`${edge} ${pad(l)} ${edge}`) + nl);
|
|
270
|
+
write(line(c.gray(`└${"─".repeat(inner + 2)}┘`)) + nl + line() + nl);
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
/** The closing: the foot of the rail and a few lines after it. */
|
|
274
|
+
outro(lines = []) {
|
|
275
|
+
write(`${rail.end} ${lines[0] ?? ""}` + nl);
|
|
276
|
+
for (const l of lines.slice(1)) write(` ${l}` + nl);
|
|
277
|
+
write(nl);
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* A step that takes a while. `done`/`fail`/`skip` replace the spinner line with the outcome.
|
|
282
|
+
* Without a terminal the label is printed once and the outcome after it.
|
|
283
|
+
*/
|
|
284
|
+
task(label) {
|
|
285
|
+
let frame = 0;
|
|
286
|
+
let timer = null;
|
|
287
|
+
const paint = painter();
|
|
288
|
+
const draw = () => paint.draw([`${c.cyan(FRAMES[frame++ % FRAMES.length])} ${label}`]);
|
|
289
|
+
if (tty) {
|
|
290
|
+
write("\x1b[?25l");
|
|
291
|
+
draw();
|
|
292
|
+
timer = setInterval(draw, 80);
|
|
293
|
+
} else {
|
|
294
|
+
write(`… ${label}` + nl);
|
|
295
|
+
}
|
|
296
|
+
const finish = (mark, text, detail) => {
|
|
297
|
+
if (timer) clearInterval(timer);
|
|
298
|
+
timer = null;
|
|
299
|
+
if (tty) {
|
|
300
|
+
paint.clear();
|
|
301
|
+
write("\x1b[?25h");
|
|
302
|
+
}
|
|
303
|
+
write(`${mark} ${text}` + (detail ? nl + line(c.dim(detail)) : "") + nl + line() + nl);
|
|
304
|
+
};
|
|
305
|
+
return {
|
|
306
|
+
update(text) {
|
|
307
|
+
label = text;
|
|
308
|
+
if (!tty) write(`… ${label}` + nl);
|
|
309
|
+
},
|
|
310
|
+
done: (text = label, detail = "") => finish(rail.done, text, detail),
|
|
311
|
+
fail: (text = label, detail = "") => finish(rail.fail, c.red(text), detail),
|
|
312
|
+
skip: (text = label, detail = "") => finish(c.gray(RAIL.done), c.dim(text), detail),
|
|
313
|
+
};
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
/** Run `fn` under a spinner. `done(result)` and `fail(error)` word the outcome line. */
|
|
317
|
+
async run(label, fn, { done = () => label, fail = (e) => `${label}: ${e.message}` } = {}) {
|
|
318
|
+
const t = ui.task(label);
|
|
319
|
+
try {
|
|
320
|
+
const result = await fn(t);
|
|
321
|
+
const text = done(result);
|
|
322
|
+
if (typeof text === "object" && text) t.done(text.text ?? label, text.detail ?? "");
|
|
323
|
+
else t.done(text || label);
|
|
324
|
+
return result;
|
|
325
|
+
} catch (e) {
|
|
326
|
+
t.fail(fail(e));
|
|
327
|
+
throw e;
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Pick one of `choices` ({ value, label, hint?, preview? }). Arrow keys move, Enter picks; Space
|
|
333
|
+
* runs the highlighted choice's `preview` if it has one (a sound, a voice), and says so meanwhile.
|
|
334
|
+
*/
|
|
335
|
+
async select({ message, choices, initial, previewHint = "space to hear it" }) {
|
|
336
|
+
let index = Math.max(0, choices.findIndex((ch) => ch.value === initial));
|
|
337
|
+
const hasPreview = choices.some((ch) => typeof ch.preview === "function");
|
|
338
|
+
let status = "";
|
|
339
|
+
let previewing = false;
|
|
340
|
+
const labelWidth = Math.max(...choices.map((ch) => width(ch.label)));
|
|
341
|
+
const draw = () => {
|
|
342
|
+
const lines = [`${rail.ask} ${c.bold(message)}`];
|
|
343
|
+
choices.forEach((ch, i) => {
|
|
344
|
+
const on = i === index;
|
|
345
|
+
const mark = on ? c.cyan(RAIL.radioOn) : c.gray(RAIL.radioOff);
|
|
346
|
+
const pad = " ".repeat(labelWidth - width(ch.label) + 2);
|
|
347
|
+
lines.push(line(`${mark} ${on ? c.bold(ch.label) : ch.label}${ch.hint ? pad + c.dim(ch.hint) : ""}`));
|
|
348
|
+
});
|
|
349
|
+
const keys = ["↑↓ move", "enter choose", ...(hasPreview ? [previewHint] : [])].join(" · ");
|
|
350
|
+
lines.push(`${rail.end} ${status ? c.cyan(status) : c.dim(keys)}`);
|
|
351
|
+
return lines;
|
|
352
|
+
};
|
|
353
|
+
await prompt(draw, (key, redraw) => {
|
|
354
|
+
if (key.name === "up" || (key.name === "char" && key.ch === "k")) index = (index + choices.length - 1) % choices.length;
|
|
355
|
+
else if (key.name === "down" || key.name === "tab" || (key.name === "char" && key.ch === "j")) index = (index + 1) % choices.length;
|
|
356
|
+
else if (key.name === "home") index = 0;
|
|
357
|
+
else if (key.name === "end") index = choices.length - 1;
|
|
358
|
+
else if (key.name === "char" && /^[1-9]$/.test(key.ch) && Number(key.ch) <= choices.length) index = Number(key.ch) - 1;
|
|
359
|
+
else if (isEnter(key)) return true;
|
|
360
|
+
else if (key.name === "space" && choices[index].preview && !previewing) {
|
|
361
|
+
previewing = true;
|
|
362
|
+
const chosen = choices[index];
|
|
363
|
+
status = chosen.previewing ?? `playing ${chosen.label}…`;
|
|
364
|
+
Promise.resolve()
|
|
365
|
+
.then(() => chosen.preview())
|
|
366
|
+
.then(() => (status = ""), (e) => (status = `couldn't play: ${e.message}`))
|
|
367
|
+
.finally(() => {
|
|
368
|
+
previewing = false;
|
|
369
|
+
redraw();
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
return false;
|
|
373
|
+
});
|
|
374
|
+
const chosen = choices[index];
|
|
375
|
+
answered(message, chosen.label);
|
|
376
|
+
return chosen.value;
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
/** Tick any number of `choices` ({ value, label, hint?, checked? }). Space toggles, Enter confirms. */
|
|
380
|
+
async multiselect({ message, choices, min = 0 }) {
|
|
381
|
+
let index = 0;
|
|
382
|
+
const checked = choices.map((ch) => Boolean(ch.checked));
|
|
383
|
+
let warning = "";
|
|
384
|
+
const labelWidth = Math.max(...choices.map((ch) => width(ch.label)));
|
|
385
|
+
const draw = () => {
|
|
386
|
+
const lines = [`${rail.ask} ${c.bold(message)}`];
|
|
387
|
+
choices.forEach((ch, i) => {
|
|
388
|
+
const on = i === index;
|
|
389
|
+
const mark = checked[i] ? c.green(RAIL.boxOn) : c.gray(RAIL.boxOff);
|
|
390
|
+
const label = on ? c.bold(ch.label) : checked[i] ? ch.label : c.dim(ch.label);
|
|
391
|
+
const pad = " ".repeat(labelWidth - width(ch.label) + 2);
|
|
392
|
+
lines.push(line(`${mark} ${label}${ch.hint ? pad + c.dim(ch.hint) : ""}`));
|
|
393
|
+
});
|
|
394
|
+
lines.push(`${rail.end} ${warning ? c.yellow(warning) : c.dim("↑↓ move · space toggle · a all · enter continue")}`);
|
|
395
|
+
return lines;
|
|
396
|
+
};
|
|
397
|
+
await prompt(draw, (key) => {
|
|
398
|
+
warning = "";
|
|
399
|
+
if (key.name === "up" || (key.name === "char" && key.ch === "k")) index = (index + choices.length - 1) % choices.length;
|
|
400
|
+
else if (key.name === "down" || key.name === "tab" || (key.name === "char" && key.ch === "j")) index = (index + 1) % choices.length;
|
|
401
|
+
else if (key.name === "space") checked[index] = !checked[index];
|
|
402
|
+
else if (key.name === "char" && key.ch === "a") checked.fill(!checked.every(Boolean));
|
|
403
|
+
else if (key.name === "char" && /^[1-9]$/.test(key.ch) && Number(key.ch) <= choices.length) checked[Number(key.ch) - 1] = !checked[Number(key.ch) - 1];
|
|
404
|
+
else if (isEnter(key)) {
|
|
405
|
+
if (checked.filter(Boolean).length >= min) return true;
|
|
406
|
+
warning = min === 1 ? "pick at least one" : `pick at least ${min}`;
|
|
407
|
+
}
|
|
408
|
+
return false;
|
|
409
|
+
});
|
|
410
|
+
const picked = choices.filter((_, i) => checked[i]);
|
|
411
|
+
answered(message, picked.length ? picked.map((ch) => ch.label).join(", ") : "none");
|
|
412
|
+
return picked.map((ch) => ch.value);
|
|
413
|
+
},
|
|
414
|
+
|
|
415
|
+
/** Yes or no. Arrows or y/n move, Enter confirms. */
|
|
416
|
+
async confirm({ message, initial = true, yes = "Yes", no = "No", hint = "" }) {
|
|
417
|
+
let value = Boolean(initial);
|
|
418
|
+
const pick = (on, label) => (on ? `${c.cyan(RAIL.radioOn)} ${c.bold(label)}` : `${c.gray(RAIL.radioOff)} ${c.dim(label)}`);
|
|
419
|
+
const draw = () => [
|
|
420
|
+
`${rail.ask} ${c.bold(message)}` + (hint ? ` ${c.dim(hint)}` : ""),
|
|
421
|
+
line(`${pick(value, yes)} ${pick(!value, no)}`),
|
|
422
|
+
`${rail.end} ${c.dim("←→ or y/n · enter confirm")}`,
|
|
423
|
+
];
|
|
424
|
+
await prompt(draw, (key) => {
|
|
425
|
+
if (["left", "right", "up", "down", "tab", "space"].includes(key.name)) value = !value;
|
|
426
|
+
else if (key.name === "char" && /^y$/i.test(key.ch)) value = true;
|
|
427
|
+
else if (key.name === "char" && /^n$/i.test(key.ch)) value = false;
|
|
428
|
+
else if (isEnter(key)) return true;
|
|
429
|
+
return false;
|
|
430
|
+
});
|
|
431
|
+
answered(message, value ? yes : no);
|
|
432
|
+
return value;
|
|
433
|
+
},
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* A line of text. `mask` hides what is typed (a key); `validate` returns a message to show
|
|
437
|
+
* instead of accepting; `placeholder` shows while empty, and an empty Enter returns "".
|
|
438
|
+
*/
|
|
439
|
+
async text({ message, placeholder = "", initial = "", mask = false, hint = "", validate = () => "" }) {
|
|
440
|
+
let value = String(initial);
|
|
441
|
+
let warning = "";
|
|
442
|
+
const draw = () => [
|
|
443
|
+
`${rail.ask} ${c.bold(message)}` + (hint ? ` ${c.dim(hint)}` : ""),
|
|
444
|
+
line(`${value ? (mask ? "•".repeat(Math.min(value.length, 32)) : value) : c.dim(placeholder)}${c.inverse(" ")}`),
|
|
445
|
+
`${rail.end} ${warning ? c.yellow(warning) : c.dim("enter to continue")}`,
|
|
446
|
+
];
|
|
447
|
+
await prompt(draw, (key) => {
|
|
448
|
+
warning = "";
|
|
449
|
+
if (key.name === "backspace") value = value.slice(0, -1);
|
|
450
|
+
else if (key.name === "space") value += " ";
|
|
451
|
+
else if (key.name === "char") value += key.ch;
|
|
452
|
+
if (isEnter(key)) {
|
|
453
|
+
value = value.trim();
|
|
454
|
+
const problem = validate(value);
|
|
455
|
+
if (!problem) return true;
|
|
456
|
+
warning = problem;
|
|
457
|
+
}
|
|
458
|
+
return false;
|
|
459
|
+
});
|
|
460
|
+
answered(message, value ? (mask ? "•".repeat(8) : value) : "(empty)");
|
|
461
|
+
return value;
|
|
462
|
+
},
|
|
463
|
+
};
|
|
464
|
+
return ui;
|
|
465
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"display_information": {
|
|
3
|
+
"name": "roger-roger",
|
|
4
|
+
"description": "Lets your AI coding agent ping you, and ask you to decide, when you're away from the screen.",
|
|
5
|
+
"background_color": "#1f2329"
|
|
6
|
+
},
|
|
7
|
+
"features": {
|
|
8
|
+
"app_home": {
|
|
9
|
+
"home_tab_enabled": false,
|
|
10
|
+
"messages_tab_enabled": true,
|
|
11
|
+
"messages_tab_read_only_enabled": false
|
|
12
|
+
},
|
|
13
|
+
"bot_user": {
|
|
14
|
+
"display_name": "roger-roger",
|
|
15
|
+
"always_online": true
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"oauth_config": {
|
|
19
|
+
"scopes": {
|
|
20
|
+
"bot": ["chat:write", "im:write", "im:history", "files:read", "files:write", "reactions:write"]
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"settings": {
|
|
24
|
+
"event_subscriptions": {
|
|
25
|
+
"bot_events": ["message.im"]
|
|
26
|
+
},
|
|
27
|
+
"interactivity": {
|
|
28
|
+
"is_enabled": true
|
|
29
|
+
},
|
|
30
|
+
"org_deploy_enabled": false,
|
|
31
|
+
"socket_mode_enabled": true,
|
|
32
|
+
"token_rotation_enabled": false
|
|
33
|
+
}
|
|
34
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|