@impeccable/reviews 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/README.md +68 -0
- package/dist/agent.js +183 -0
- package/dist/bin.js +11 -0
- package/dist/fence.js +1071 -0
- package/dist/focus.js +98 -0
- package/dist/host-questions.js +71 -0
- package/dist/hosted.js +195 -0
- package/dist/http.js +36 -0
- package/dist/log.js +38 -0
- package/dist/login.js +276 -0
- package/dist/main.js +252 -0
- package/dist/netlify.js +333 -0
- package/dist/playbook/cases/ci-tunnel.md +21 -0
- package/dist/playbook/cases/first-preview-failure.md +28 -0
- package/dist/playbook/cases/netlify-previews.md +17 -0
- package/dist/playbook/cases/netlify-setup.md +24 -0
- package/dist/playbook/cases/preview-protection.md +21 -0
- package/dist/playbook/cases/pull-requests.md +31 -0
- package/dist/playbook/cases/sign-in.md +21 -0
- package/dist/playbook/cases/static-site.md +16 -0
- package/dist/playbook/cases/vercel-previews.md +16 -0
- package/dist/playbook/cases/vercel-setup.md +27 -0
- package/dist/playbook/cases/workflow-as-person.md +23 -0
- package/dist/playbook/github-previews.js +101 -0
- package/dist/playbook/netlify-pattern.js +24 -0
- package/dist/playbook/netlify-previews.js +144 -0
- package/dist/playbook/probe-preview.js +67 -0
- package/dist/playbook/protection-secrets.js +15 -0
- package/dist/playbook/public-preview-request.js +96 -0
- package/dist/playbook/publication-wait.js +46 -0
- package/dist/playbook/vercel-protection.js +121 -0
- package/dist/playbook.js +98 -0
- package/dist/preflight.js +211 -0
- package/dist/stages.js +34 -0
- package/dist/sync.js +142 -0
- package/dist/tools.js +2015 -0
- package/dist/transcript.js +139 -0
- package/dist/ui/bridge.js +145 -0
- package/dist/ui/frames.js +62 -0
- package/dist/ui/look.js +368 -0
- package/dist/ui/run-state.js +91 -0
- package/dist/ui/screens.js +544 -0
- package/dist/ui/sensitive.js +14 -0
- package/dist/vercel-bypass.js +136 -0
- package/dist/vercel.js +70 -0
- package/package.json +39 -0
package/dist/ui/look.js
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
|
|
2
|
+
import { fitsTask, taskInProgress, workStarted } from '../stages.js';
|
|
3
|
+
/**
|
|
4
|
+
* How the wizard's screens look, drawn as lines from what they show and the
|
|
5
|
+
* terminal's size, with no terminal behind them: the smoke renders every
|
|
6
|
+
* screen this way at any width. Every screen is one frame: a box holding the
|
|
7
|
+
* mark, one heading, a line or two of supporting text, a divider, the action
|
|
8
|
+
* line and at most two ways on, everything centered, with the version under
|
|
9
|
+
* the box on the right. Only the middle changes from screen to screen.
|
|
10
|
+
*/
|
|
11
|
+
export const PRODUCT = 'Impeccable for PRs';
|
|
12
|
+
export const FEEDBACK = 'support@renaissance-geek.ai';
|
|
13
|
+
/** The box fills the terminal up to this size, then sits in the middle of it. */
|
|
14
|
+
const BOX_WIDTH = 100;
|
|
15
|
+
const BOX_HEIGHT = 32;
|
|
16
|
+
/** The widest supporting text runs, so it reads as a line or two. */
|
|
17
|
+
const TEXT_WIDTH = 60;
|
|
18
|
+
/** The widest an answer field runs. */
|
|
19
|
+
const FIELD_WIDTH = 48;
|
|
20
|
+
/** The running task's spinner: one glyph every third frame. */
|
|
21
|
+
const SPINNER = ['◐', '◓', '◑', '◒'];
|
|
22
|
+
const START = { heading: "Let's set up your app", text: 'This wizard detects your framework and wires up all the essentials.' };
|
|
23
|
+
/** The invitation to the test drive, on whichever end screen shows it; the words "test drive" stay off it. */
|
|
24
|
+
export const SEE_THE_REVIEW = 'See Impeccable review a change: we opened a small pull request just for that.';
|
|
25
|
+
const WORDMARK = 'I M P E C C A B L E';
|
|
26
|
+
/** The wordmark, IMPECCABLE with for PRs beside it on one row, centered; plain text on a dumb terminal. */
|
|
27
|
+
function markRows(inner, drawing, ink) {
|
|
28
|
+
if (drawing === 'plain')
|
|
29
|
+
return [center('/ IMPECCABLE for PRs', inner), ''];
|
|
30
|
+
return [center(`${ink.heading(WORDMARK)} ${ink.soft('for PRs')}`, inner), ''];
|
|
31
|
+
}
|
|
32
|
+
export function palette(background, drawing = 'blocks') {
|
|
33
|
+
const sgr = (on, off) => (s) => `\x1b[${on}m${s}\x1b[${off}m`;
|
|
34
|
+
const link = (url, text = url) => `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
|
|
35
|
+
if (!background || process.env.NO_COLOR) {
|
|
36
|
+
const bold = sgr('1', '22');
|
|
37
|
+
const dim = sgr('2', '22');
|
|
38
|
+
const ink = { full: bold, soft: (s) => s, faint: dim, heading: bold };
|
|
39
|
+
return { ...ink, mark: (inner) => markRows(inner, drawing, ink), link };
|
|
40
|
+
}
|
|
41
|
+
const { r, g, b } = background;
|
|
42
|
+
const ink = 0.299 * r + 0.587 * g + 0.114 * b < 128 ? 255 : 0;
|
|
43
|
+
const grey = (p) => [r, g, b].map((c) => Math.round(c + p * (ink - c))).join(';');
|
|
44
|
+
const fg = (p) => sgr(`38;2;${grey(p)}`, '39');
|
|
45
|
+
const levels = { full: fg(1), soft: fg(0.7), faint: fg(0.4), heading: (s) => `\x1b[1m${fg(1)(s)}\x1b[22m` };
|
|
46
|
+
return { ...levels, mark: (inner) => markRows(inner, drawing, levels), link };
|
|
47
|
+
}
|
|
48
|
+
export const OUTCOME = { done: '✓', partial: '!', stopped: '✗' };
|
|
49
|
+
/** A line centered in `width`, cut short when it does not fit. */
|
|
50
|
+
function center(line, width) {
|
|
51
|
+
const w = visibleWidth(line);
|
|
52
|
+
return w >= width ? truncateToWidth(line, width, '…') : ' '.repeat(Math.floor((width - w) / 2)) + line;
|
|
53
|
+
}
|
|
54
|
+
/** Lines that share one left edge, centered together as a block `size` wide (their widest, unless given): a list whose marks line up. */
|
|
55
|
+
function block(lines, width, size = Math.max(0, ...lines.map((line) => visibleWidth(line)))) {
|
|
56
|
+
const left = ' '.repeat(Math.max(0, Math.floor((width - Math.min(size, width)) / 2)));
|
|
57
|
+
return lines.map((line) => left + truncateToWidth(line, width - left.length, '…'));
|
|
58
|
+
}
|
|
59
|
+
/** Plain text wrapped to at most `max` columns: a sentence a line when each fits, else lines as even as the words allow. */
|
|
60
|
+
function balanced(text, max) {
|
|
61
|
+
const sentences = text.split(/(?<=[.!?])\s+/);
|
|
62
|
+
if (sentences.length > 1 && sentences.every((line) => visibleWidth(line) <= max))
|
|
63
|
+
return sentences;
|
|
64
|
+
const lines = wrapTextWithAnsi(text, Math.max(10, max));
|
|
65
|
+
for (let w = Math.ceil(visibleWidth(text) / lines.length); lines.length > 1 && w < max; w++) {
|
|
66
|
+
const even = wrapTextWithAnsi(text, w);
|
|
67
|
+
if (even.length === lines.length)
|
|
68
|
+
return even;
|
|
69
|
+
}
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
|
72
|
+
/** Supporting text: wrapped, soft, each line centered. */
|
|
73
|
+
const said = (text, inner, st) => balanced(text, Math.min(TEXT_WIDTH, inner)).map((line) => center(st.soft(line), inner));
|
|
74
|
+
/** Wrapped text whose first line starts with `first` and the rest line up under it. */
|
|
75
|
+
const hang = (text, width, first) => wrapTextWithAnsi(text, Math.max(10, width - first.length)).map((line, i) => (i ? ' '.repeat(first.length) : first) + line);
|
|
76
|
+
/** An address cut into rows no wider than `width`, after a slash where one falls in the row's last two thirds, each linking to the whole of it, so any row opens it. */
|
|
77
|
+
function urlRows(url, width, ink, st) {
|
|
78
|
+
const size = Math.max(10, width);
|
|
79
|
+
const rows = [];
|
|
80
|
+
for (let rest = url; rest;) {
|
|
81
|
+
const slash = rest.length > size ? rest.lastIndexOf('/', size - 1) + 1 : 0;
|
|
82
|
+
const cut = rest.length <= size ? rest.length : slash > size / 3 ? slash : size;
|
|
83
|
+
rows.push(rest.slice(0, cut));
|
|
84
|
+
rest = rest.slice(cut);
|
|
85
|
+
}
|
|
86
|
+
return rows.map((row) => st.link(url, ink(row)));
|
|
87
|
+
}
|
|
88
|
+
/** A label, softer, over its address. */
|
|
89
|
+
const linkLines = (label, url, inner, st) => [center(st.soft(label), inner), ...block(urlRows(url, ruleWidth(inner), st.full, st), inner)];
|
|
90
|
+
/** The way to put a link on the clipboard, while it is not there yet. */
|
|
91
|
+
const copyWay = (inner, st) => center(`${st.soft('Copy link')} ${st.faint('[C]')}`, inner);
|
|
92
|
+
/** A pull request the screen is about: its link, whole, and under it that it is on the clipboard, or the way to put it there. */
|
|
93
|
+
const pullAction = (url, copied, inner, st) => [
|
|
94
|
+
...block(urlRows(url, ruleWidth(inner), st.full, st), inner),
|
|
95
|
+
copied ? center(st.soft('Copied to clipboard!'), inner) : copyWay(inner, st),
|
|
96
|
+
];
|
|
97
|
+
/** What waits on the person, under the supporting text: two lines at most, the rest counted. */
|
|
98
|
+
function leftLines(todo, inner, st) {
|
|
99
|
+
const shown = todo.length > 2 ? [todo[0], `${todo.length - 1} more in the report`] : todo;
|
|
100
|
+
return shown.map((line) => center(st.soft(`→ ${line}`), inner));
|
|
101
|
+
}
|
|
102
|
+
/** The ways on, the first at full intensity and the second softer, each with its key in brackets. */
|
|
103
|
+
const waysOn = (ways, st) => ways.map(([label, key], i) => `${i ? st.soft(label) : st.full(label)} ${st.faint(`[${key}]`)}`).join(' ');
|
|
104
|
+
const ruleWidth = (inner) => Math.min(inner, Math.max(20, Math.round(inner * 0.72)));
|
|
105
|
+
const divider = (inner, st) => st.faint('─'.repeat(ruleWidth(inner)));
|
|
106
|
+
/** Nine faint dots, one full dot passing along them and back, a dot every other frame. */
|
|
107
|
+
const dots = (_width, frame, st) => {
|
|
108
|
+
const count = 9;
|
|
109
|
+
const step = Math.floor(frame / 2) % (2 * (count - 1));
|
|
110
|
+
const lit = step < count ? step : 2 * (count - 1) - step;
|
|
111
|
+
return [Array.from({ length: count }, (_, i) => (i === lit ? st.full('●') : st.faint('·'))).join(' ')];
|
|
112
|
+
};
|
|
113
|
+
const loader = dots;
|
|
114
|
+
/**
|
|
115
|
+
* The action in `room` rows: whole when it fits, else a window over it that
|
|
116
|
+
* opens at the top when that shows its `at` line, else around it, and moves
|
|
117
|
+
* `scroll.by` rows from there, a faint line in its first or last row saying
|
|
118
|
+
* how many more lie that way.
|
|
119
|
+
*/
|
|
120
|
+
function windowed(lines, room, at, scroll, inner, st) {
|
|
121
|
+
const last = Math.max(0, lines.length - room);
|
|
122
|
+
const home = at <= room - 2 ? 0 : Math.min(Math.max(0, at - Math.floor(room / 2)), last);
|
|
123
|
+
const top = Math.min(Math.max(0, home + scroll.by), last);
|
|
124
|
+
// Under three rows there is no room to say how many more lie above or below.
|
|
125
|
+
const [above, below] = [room >= 3 && top > 0, room >= 3 && top < last];
|
|
126
|
+
scroll.by = top - home;
|
|
127
|
+
scroll.page = Math.max(1, room - 2);
|
|
128
|
+
scroll.hidden = at < top + Number(above) || at > top + room - 1 - Number(below);
|
|
129
|
+
if (!last)
|
|
130
|
+
return lines;
|
|
131
|
+
const shown = lines.slice(top, top + room);
|
|
132
|
+
const more = (from, to) => lines.slice(from, to).filter(Boolean).length;
|
|
133
|
+
if (above)
|
|
134
|
+
shown[0] = center(st.faint(`… ${more(0, top + 1)} more above`), inner);
|
|
135
|
+
if (below)
|
|
136
|
+
shown[room - 1] = center(st.faint(`… ${more(top + room - 1, lines.length)} more below`), inner);
|
|
137
|
+
return shown;
|
|
138
|
+
}
|
|
139
|
+
/** An answer field in a box `width` wide, its label in the top edge, the line the field draws inside without its prompt (the input's `> ` or the wizard's `› `): full while it has the cursor, faint otherwise. */
|
|
140
|
+
function fieldBox(label, line, width, focused, st) {
|
|
141
|
+
const edge = focused ? st.full : st.faint;
|
|
142
|
+
const value = truncateToWidth(line.replace(/^[›>] /, ''), width - 4, '');
|
|
143
|
+
const top = label ? `┌ ${label} ${'─'.repeat(Math.max(0, width - visibleWidth(label) - 4))}┐` : `┌${'─'.repeat(width - 2)}┐`;
|
|
144
|
+
return [edge(top), `${edge('│')} ${(focused ? st.full : st.soft)(value)}${' '.repeat(Math.max(0, width - 4 - visibleWidth(value)))} ${edge('│')}`, edge(`└${'─'.repeat(width - 2)}┘`)];
|
|
145
|
+
}
|
|
146
|
+
function questionPage(qv, inner, st) {
|
|
147
|
+
const { q } = qv;
|
|
148
|
+
const note = qv.note ? [center(st.full(qv.note), inner)] : [];
|
|
149
|
+
// Go on or skip: the text under the title, then the question itself as the action line, and two ways on the question names.
|
|
150
|
+
if (q.kind === 'confirm') {
|
|
151
|
+
return {
|
|
152
|
+
heading: q.title ?? 'A question for you',
|
|
153
|
+
text: q.hint ? said(q.hint, inner, st) : [],
|
|
154
|
+
rule: divider(inner, st),
|
|
155
|
+
action: [center(st.full(q.prompt), inner), ...note],
|
|
156
|
+
asks: true,
|
|
157
|
+
ways: [[q.confirmLabel ?? 'Continue', 'Enter'], [q.skipLabel ?? 'Skip', 'Esc']],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
// One test account: both fields under the divider, each in a box with its label in the top edge, the password as dots.
|
|
161
|
+
if (q.kind === 'account') {
|
|
162
|
+
const width = Math.min(FIELD_WIDTH, inner);
|
|
163
|
+
const label = (text) => ` ${st.soft(text)}`;
|
|
164
|
+
const hint = q.hint ? [...wrapTextWithAnsi(q.hint, width - 2).map(label), ''] : [];
|
|
165
|
+
const fields = [...fieldBox('Username or email', qv.field(width - 2), width, qv.focus !== 1, st), ...fieldBox('Password', qv.password?.() ?? '', width, qv.focus === 1, st)];
|
|
166
|
+
return {
|
|
167
|
+
heading: q.title ?? 'A test account',
|
|
168
|
+
text: said(q.prompt, inner, st),
|
|
169
|
+
rule: divider(inner, st),
|
|
170
|
+
action: [...block([...hint, ...fields], inner, width), ...note],
|
|
171
|
+
at: hint.length + (qv.focus === 1 ? 4 : 1),
|
|
172
|
+
asks: true,
|
|
173
|
+
ways: [['Continue', 'Enter'], ['Skip', 'Esc']],
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
const choices = q.kind !== 'text' && q.options?.length ? q.options : null;
|
|
177
|
+
const hint = q.hint && choices ? said(q.hint, inner, st) : [];
|
|
178
|
+
const after = [
|
|
179
|
+
...(choices ? [center(st.faint(q.kind === 'multi' ? 'Move [↑↓] Pick [Space]' : 'Choose [↑↓]'), inner)] : []),
|
|
180
|
+
...note,
|
|
181
|
+
];
|
|
182
|
+
// A field's hint is its label, in the box's top edge when it fits there, else over the box on the same left edge.
|
|
183
|
+
const field = Math.min(FIELD_WIDTH, inner);
|
|
184
|
+
const edged = q.hint && visibleWidth(q.hint) <= field - 6 ? q.hint : '';
|
|
185
|
+
const label = q.hint && !edged ? wrapTextWithAnsi(q.hint, field - 2).map((line) => ` ${st.soft(line)}`) : [];
|
|
186
|
+
const lines = (choices ?? []).map((option, i) => {
|
|
187
|
+
const here = i === qv.cursor;
|
|
188
|
+
const mark = q.kind === 'single' ? (here ? '●' : '○') : qv.picked.has(i) ? '[x]' : '[ ]';
|
|
189
|
+
return (here ? st.full : st.soft)(`${here ? '›' : ' '} ${mark} ${option.label}`);
|
|
190
|
+
});
|
|
191
|
+
const widest = Math.max(0, ...lines.map((line) => visibleWidth(line)));
|
|
192
|
+
return {
|
|
193
|
+
heading: q.title ?? 'A question for you',
|
|
194
|
+
text: said(q.prompt, inner, st),
|
|
195
|
+
rule: divider(inner, st),
|
|
196
|
+
action: choices ? [...hint, ...block(lines, inner, widest), ...after] : [...block([...label, ...fieldBox(edged, qv.field(field - 2), field, true, st)], inner, field), ...after],
|
|
197
|
+
at: choices ? hint.length + qv.cursor : label.length + 1,
|
|
198
|
+
asks: true,
|
|
199
|
+
ways: [['Continue', 'Enter'], ...(q.skippable ? [['Skip', 'Esc']] : [])],
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* The run: understanding the app, the status band's two rows under the
|
|
204
|
+
* loader, until the work after reading starts; then the tasks alone. The
|
|
205
|
+
* status line is the detail of the reading: a line about another task leaves
|
|
206
|
+
* its row empty.
|
|
207
|
+
*/
|
|
208
|
+
function runPage(scene, f, inner, st) {
|
|
209
|
+
const glyph = SPINNER[Math.floor((f.motion?.frame ?? 0) / 3) % SPINNER.length];
|
|
210
|
+
const task = taskInProgress(scene.tasks);
|
|
211
|
+
const [previous, current] = [scene.band.previous, scene.band.current].map((line) => (line && (!task || fitsTask(line, task)) ? line : undefined));
|
|
212
|
+
const status = [
|
|
213
|
+
previous ? center(st.faint(previous), inner) : '',
|
|
214
|
+
current ? center(`${f.motion?.at === 'status' ? `${st.full(glyph)} ` : ''}${st.soft(current)}`, inner) : '',
|
|
215
|
+
];
|
|
216
|
+
const around = { ways: f.stopping ? [['Stop the setup', 'Esc']] : undefined };
|
|
217
|
+
if (!workStarted(scene.tasks))
|
|
218
|
+
return { heading: 'Understanding your app', rule: loader(inner, f.motion?.frame ?? 0, st), action: status, ...around };
|
|
219
|
+
const running = scene.tasks.findIndex((t) => t.status === 'in_progress');
|
|
220
|
+
const lines = scene.tasks.map((t, i) => {
|
|
221
|
+
const mark = t.status === 'completed' ? '✓'
|
|
222
|
+
: t.status === 'in_progress' ? (i === running && f.motion?.at === 'task' ? glyph : '•')
|
|
223
|
+
: t.status === 'failed' ? '✗'
|
|
224
|
+
: t.status === 'skipped' ? '–'
|
|
225
|
+
: '○';
|
|
226
|
+
const ink = t.status === 'in_progress' || t.status === 'failed' ? st.full : t.status === 'skipped' ? st.faint : st.soft;
|
|
227
|
+
// The task in progress carries the wait's timer, so a screen waiting on a host's build is never still.
|
|
228
|
+
const detail = t.status === 'skipped' || t.status === 'failed' ? t.status : i === running && scene.wait ? scene.wait : undefined;
|
|
229
|
+
return ink(`${mark} ${t.title}${detail ? ` · ${detail}` : ''}`);
|
|
230
|
+
});
|
|
231
|
+
const counted = scene.tasks.filter((t) => t.status !== 'skipped');
|
|
232
|
+
const skipped = scene.tasks.length - counted.length;
|
|
233
|
+
const widest = Math.max(...lines.map((line) => visibleWidth(line)));
|
|
234
|
+
return {
|
|
235
|
+
heading: 'Setting up',
|
|
236
|
+
text: said(`${counted.filter((t) => t.status === 'completed').length} of ${counted.length} done${skipped ? ` · ${skipped} skipped` : ''}`, inner, st),
|
|
237
|
+
rule: divider(inner, st),
|
|
238
|
+
action: block(lines, inner, widest),
|
|
239
|
+
at: running >= 0 ? running : Math.max(0, scene.tasks.findIndex((t) => t.status === 'pending')),
|
|
240
|
+
...around,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
function outroPage(scene, inner, st) {
|
|
244
|
+
const { outro } = scene;
|
|
245
|
+
if (outro.link) {
|
|
246
|
+
return {
|
|
247
|
+
heading: outro.title,
|
|
248
|
+
text: [...said(outro.result, inner, st), ...leftLines(outro.todo, inner, st), ...outro.warnings.map((line) => center(st.soft(`! ${line}`), inner))],
|
|
249
|
+
rule: divider(inner, st),
|
|
250
|
+
action: pullAction(outro.link, scene.copied, inner, st),
|
|
251
|
+
ways: scene.leaving ? undefined : [['Close', 'Enter']],
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
const width = Math.min(TEXT_WIDTH, inner);
|
|
255
|
+
const notes = [...outro.todo.flatMap((line) => hang(line, width, '→ ').map(st.full)), ...outro.warnings.flatMap((line) => hang(line, width, '! ').map(st.soft))];
|
|
256
|
+
const links = outro.links.flatMap((l) => linkLines(l.label, l.url, inner, st));
|
|
257
|
+
return {
|
|
258
|
+
heading: outro.title,
|
|
259
|
+
text: said(outro.result, inner, st),
|
|
260
|
+
rule: divider(inner, st),
|
|
261
|
+
action: [...block(notes, inner), ...(notes.length && links.length ? [''] : []), ...links, ...(outro.links.length === 1 ? [scene.copied ? center(st.soft('Copied to clipboard!'), inner) : copyWay(inner, st)] : [])],
|
|
262
|
+
ways: scene.leaving ? undefined : [['Close', 'Enter']],
|
|
263
|
+
foot: outro.outcome === 'stopped' ? [center(st.faint(`Questions? Write to ${FEEDBACK}`), inner)] : undefined,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function pageOf(f, inner) {
|
|
267
|
+
const st = f.style;
|
|
268
|
+
if (f.question)
|
|
269
|
+
return questionPage(f.question, inner, st);
|
|
270
|
+
const { scene } = f;
|
|
271
|
+
switch (scene.kind) {
|
|
272
|
+
case 'wait':
|
|
273
|
+
return { heading: START.heading, text: said(START.text, inner, st), rule: divider(inner, st), action: [center(st.faint('One moment…'), inner)] };
|
|
274
|
+
case 'intro':
|
|
275
|
+
return { heading: START.heading, text: said(START.text, inner, st), rule: divider(inner, st), action: [center(st.full("Let's start with login"), inner)], ways: [['Continue', 'Enter']] };
|
|
276
|
+
case 'signin': {
|
|
277
|
+
// P still takes a pasted code on a remote shell; --help says so, the screen does not.
|
|
278
|
+
const under = scene.note ?? (scene.copied ? 'Copied to clipboard!' : '');
|
|
279
|
+
const link = block(urlRows(scene.link, ruleWidth(inner), st.soft, st), inner);
|
|
280
|
+
return {
|
|
281
|
+
heading: scene.again ? 'Sign in again' : START.heading,
|
|
282
|
+
text: said(scene.again ? 'Your sign-in ran out. Approve again in your browser to carry on.' : 'Sign in with GitHub in your browser, then approve this repository.', inner, st),
|
|
283
|
+
rule: divider(inner, st),
|
|
284
|
+
action: [...link, ...(under ? ['', center(st.full(under), inner)] : []), ...(scene.copied === false ? ['', copyWay(inner, st)] : [])],
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
case 'run':
|
|
288
|
+
return runPage(scene, f, inner, st);
|
|
289
|
+
case 'pull':
|
|
290
|
+
return {
|
|
291
|
+
heading: 'Your first review is on its way',
|
|
292
|
+
text: said(SEE_THE_REVIEW, inner, st),
|
|
293
|
+
rule: divider(inner, st),
|
|
294
|
+
action: pullAction(scene.url, scene.copied, inner, st),
|
|
295
|
+
ways: [['Continue', 'Enter']],
|
|
296
|
+
};
|
|
297
|
+
case 'outro':
|
|
298
|
+
return outroPage(scene, inner, st);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* The page top down in `rows` rows. The mark and the heading keep their rows
|
|
303
|
+
* on every screen of a terminal this tall, so only the middle changes; the
|
|
304
|
+
* parts under them sit two blank rows apart while they fit. On a terminal too
|
|
305
|
+
* short for the page, rows give way in one order: the blank rows (from the
|
|
306
|
+
* bottom up, two to one, then none, the space above the heading last), the
|
|
307
|
+
* faint lines at the foot, the supporting text from its end (a question's
|
|
308
|
+
* prompt excepted), the mark, the divider. The heading and the ways on stay,
|
|
309
|
+
* and the action keeps every line: when it still does not fit, it scrolls
|
|
310
|
+
* between them, a question's prompt at its top, the ways on in the last row.
|
|
311
|
+
*/
|
|
312
|
+
function stack(page, inner, rows, st, scroll) {
|
|
313
|
+
const heading = [center(st.heading(page.heading), inner)];
|
|
314
|
+
const ways = page.ways ? [center(waysOn(page.ways, st), inner)] : [];
|
|
315
|
+
let mark = st.mark(inner);
|
|
316
|
+
let text = page.text ?? [];
|
|
317
|
+
let rule = page.rule === undefined ? [] : [page.rule].flat().map((row) => center(row, inner));
|
|
318
|
+
let foot = page.foot ?? [];
|
|
319
|
+
const over = () => [mark, heading, text, rule, page.action, ways, foot].reduce((n, part) => n + part.length, 0) - rows;
|
|
320
|
+
if (over() > 0)
|
|
321
|
+
foot = [];
|
|
322
|
+
if (over() > 0 && over() < text.length && !page.asks) {
|
|
323
|
+
const keep = text.length - over();
|
|
324
|
+
text = [...text.slice(0, keep - 1), `${text[keep - 1]}${st.soft('…')}`];
|
|
325
|
+
}
|
|
326
|
+
if (over() > 0 && !page.asks)
|
|
327
|
+
text = [];
|
|
328
|
+
if (over() > 0)
|
|
329
|
+
mark = [];
|
|
330
|
+
if (over() > 0)
|
|
331
|
+
rule = [];
|
|
332
|
+
// Past this point a question's prompt scrolls with its field or choices, a blank row under it.
|
|
333
|
+
const lead = over() > 0 && page.asks ? [...text, ''] : [];
|
|
334
|
+
if (lead.length)
|
|
335
|
+
text = [];
|
|
336
|
+
const action = windowed([...lead, ...page.action], page.action.length - over(), lead.length + (page.at ?? 0), scroll, inner, st);
|
|
337
|
+
const parts = [mark, heading, text, rule, action, ways, foot].filter((part) => part.length);
|
|
338
|
+
// space[i] is the blank rows before part i, and the last is under the page; the mark's and the heading's come first.
|
|
339
|
+
const head = rows >= 26 ? 2 : rows >= 12 ? 1 : 0;
|
|
340
|
+
const space = [...parts.map((_, i) => (i <= mark.length ? head : 2)), 2];
|
|
341
|
+
const size = () => parts.reduce((n, part) => n + part.length, 0) + space.reduce((n, s) => n + s, 0);
|
|
342
|
+
const squeeze = (to, from) => {
|
|
343
|
+
for (let i = space.length - 1; i >= from && size() > rows; i--)
|
|
344
|
+
space[i] = Math.min(space[i], to);
|
|
345
|
+
};
|
|
346
|
+
squeeze(1, mark.length + 1);
|
|
347
|
+
squeeze(0, mark.length + 1);
|
|
348
|
+
squeeze(0, 0);
|
|
349
|
+
const lines = parts.flatMap((part, i) => [...Array(space[i]).fill(''), ...part]);
|
|
350
|
+
return Array.from({ length: rows }, (_, i) => lines[i] ?? '');
|
|
351
|
+
}
|
|
352
|
+
/** One whole screen: exactly `height` lines, none wider than `width`. */
|
|
353
|
+
export function drawFrame(f) {
|
|
354
|
+
const st = f.style;
|
|
355
|
+
const width = Math.max(12, Math.min(f.width, BOX_WIDTH));
|
|
356
|
+
const height = Math.max(3, Math.min(f.height - 1, BOX_HEIGHT));
|
|
357
|
+
const inner = width - 4;
|
|
358
|
+
const left = ' '.repeat(Math.max(0, Math.floor((f.width - width) / 2)));
|
|
359
|
+
const version = st.faint(`Version v${f.version}`);
|
|
360
|
+
const lines = [
|
|
361
|
+
...Array(Math.max(0, Math.floor((f.height - 1 - height) / 2))).fill(''),
|
|
362
|
+
left + st.soft(`┌${'─'.repeat(width - 2)}┐`),
|
|
363
|
+
...stack(pageOf(f, inner), inner, height - 2, st, f.scroll ?? { by: 0, page: 1, hidden: false }).map((line) => `${left}${st.soft('│')} ${truncateToWidth(line, inner, '…', true)} ${st.soft('│')}`),
|
|
364
|
+
left + st.soft(`└${'─'.repeat(width - 2)}┘`),
|
|
365
|
+
left + ' '.repeat(Math.max(0, width - visibleWidth(version))) + version,
|
|
366
|
+
];
|
|
367
|
+
return Array.from({ length: Math.max(1, f.height) }, (_, i) => truncateToWidth(lines[i] ?? '', f.width, '…'));
|
|
368
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { statusLine } from '../sync.js';
|
|
2
|
+
/** The `[STATUS]` line an event carries: an assistant message that starts with one, tag stripped. */
|
|
3
|
+
export function statusFromEvent(event) {
|
|
4
|
+
return event.type === 'message_end' ? statusLine(event.message) : null;
|
|
5
|
+
}
|
|
6
|
+
/** After this long with nothing new on screen, the status band says the run is still working. */
|
|
7
|
+
const SILENCE_MS = 60_000;
|
|
8
|
+
/** How long something has taken, the way the screen says it: "45 s", "2 min", "1 h 5 min". */
|
|
9
|
+
export function elapsed(ms) {
|
|
10
|
+
const s = Math.max(0, Math.floor(ms / 1000));
|
|
11
|
+
if (s < 60)
|
|
12
|
+
return `${s} s`;
|
|
13
|
+
const min = Math.floor(s / 60);
|
|
14
|
+
return min < 60 ? `${min} min` : `${Math.floor(min / 60)} h${min % 60 ? ` ${min % 60} min` : ''}`;
|
|
15
|
+
}
|
|
16
|
+
/** A wait's timer, to the second: "0:45", "4:59". */
|
|
17
|
+
function timer(ms) {
|
|
18
|
+
const s = Math.max(0, Math.floor(ms / 1000));
|
|
19
|
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
|
20
|
+
}
|
|
21
|
+
/** What the run screen shows: the task list and the status band's two most recent lines. */
|
|
22
|
+
export class RunState {
|
|
23
|
+
now;
|
|
24
|
+
tasks = [];
|
|
25
|
+
previous;
|
|
26
|
+
current;
|
|
27
|
+
/** A bounded wait under way: the current line carries its timer, counting up to the limit. */
|
|
28
|
+
waiting;
|
|
29
|
+
/** When something last changed on screen: a status, the task list, an answered question. */
|
|
30
|
+
changedAt;
|
|
31
|
+
constructor(now = Date.now, first) {
|
|
32
|
+
this.now = now;
|
|
33
|
+
this.changedAt = now();
|
|
34
|
+
this.current = first;
|
|
35
|
+
}
|
|
36
|
+
/** A new current line, which ends the wait the last one carried. */
|
|
37
|
+
status(line) {
|
|
38
|
+
const text = line.trim().replace(/^\[STATUS\]\s*/, '').split('\n')[0].trim();
|
|
39
|
+
if (!text)
|
|
40
|
+
return;
|
|
41
|
+
if (text !== this.current) {
|
|
42
|
+
this.previous = this.current;
|
|
43
|
+
this.current = text;
|
|
44
|
+
}
|
|
45
|
+
this.waiting = undefined;
|
|
46
|
+
this.touch();
|
|
47
|
+
}
|
|
48
|
+
/** A bounded wait starts, its status line reported just before, or ends. */
|
|
49
|
+
wait(wait) {
|
|
50
|
+
this.waiting = wait ?? undefined;
|
|
51
|
+
this.touch();
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The wait's timer, "1:05 of 5:00", while one is under way: the task in progress carries it on the tasks screen.
|
|
55
|
+
* `live` false gives the wait's start, "0:00 of 5:00", the same on every tick: a drawing that changes only when
|
|
56
|
+
* a wait begins or ends, so the frames record the wait once rather than once a second.
|
|
57
|
+
*/
|
|
58
|
+
timer(live = true) {
|
|
59
|
+
return this.waiting ? `${timer(live ? this.now() - this.waiting.since : 0)} of ${timer(this.waiting.limitMs)}` : undefined;
|
|
60
|
+
}
|
|
61
|
+
setTasks(tasks) {
|
|
62
|
+
this.tasks = tasks.map((t) => ({ ...t }));
|
|
63
|
+
this.touch();
|
|
64
|
+
}
|
|
65
|
+
/** Something happened that the person saw, so the silence starts again. */
|
|
66
|
+
touch() {
|
|
67
|
+
this.changedAt = this.now();
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The band's two lines as they read now: after a minute with nothing new the
|
|
71
|
+
* last line moves up, dimmed, under "Still working…" and the time since.
|
|
72
|
+
* `quiet` is false while the person is being asked. During a bounded wait
|
|
73
|
+
* the current line carries the timer, "1:05 of 5:00", so the screen never
|
|
74
|
+
* looks stalled; `live` false holds the timer at the wait's start, for a
|
|
75
|
+
* drawing that changes only when the words do.
|
|
76
|
+
*/
|
|
77
|
+
band(quiet = true, live = true) {
|
|
78
|
+
if (this.waiting && this.current) {
|
|
79
|
+
return { previous: this.previous, current: `${this.current} · ${this.timer(live)}` };
|
|
80
|
+
}
|
|
81
|
+
const silence = this.now() - this.changedAt;
|
|
82
|
+
if (quiet && silence >= SILENCE_MS)
|
|
83
|
+
return { previous: this.current, current: `Still working… ${elapsed(silence)}` };
|
|
84
|
+
return { previous: this.previous, current: this.current };
|
|
85
|
+
}
|
|
86
|
+
/** Completed tasks out of all but the skipped ones. */
|
|
87
|
+
progress() {
|
|
88
|
+
const counted = this.tasks.filter((t) => t.status !== 'skipped');
|
|
89
|
+
return { completed: counted.filter((t) => t.status === 'completed').length, total: counted.length };
|
|
90
|
+
}
|
|
91
|
+
}
|