unfold-deploy 0.0.0-stage → 0.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +150 -2
  4. package/bin/unfold.js +14 -0
  5. package/demos/claude-success.json +72 -0
  6. package/demos/codex-checks-failing.json +62 -0
  7. package/demos/gcloud-deploy-failure.json +616 -0
  8. package/demos/gcloud-success.json +608 -0
  9. package/demos/vercel-build-failure.json +331 -0
  10. package/demos/vercel-interrupted.json +271 -0
  11. package/demos/vercel-success.json +343 -0
  12. package/dist/cli.js +121 -0
  13. package/dist/commands/demo.js +75 -0
  14. package/dist/commands/observe.js +137 -0
  15. package/dist/commands/replay.js +76 -0
  16. package/dist/commands/run.js +24 -0
  17. package/dist/commands/show.js +44 -0
  18. package/dist/core/errors.js +3 -0
  19. package/dist/core/fix-prompt.js +85 -0
  20. package/dist/core/flow.js +211 -0
  21. package/dist/core/launch.js +196 -0
  22. package/dist/core/model.js +3 -0
  23. package/dist/core/redact.js +79 -0
  24. package/dist/core/server.js +109 -0
  25. package/dist/core/session.js +136 -0
  26. package/dist/core/store.js +52 -0
  27. package/dist/core/terminal.js +109 -0
  28. package/dist/providers/agents/claude.js +205 -0
  29. package/dist/providers/agents/codex.js +152 -0
  30. package/dist/providers/agents/shared.js +119 -0
  31. package/dist/providers/gcloud/index.js +39 -0
  32. package/dist/providers/gcloud/parser.js +149 -0
  33. package/dist/providers/gcloud/stages.js +55 -0
  34. package/dist/providers/index.js +5 -0
  35. package/dist/providers/provider.js +1 -0
  36. package/dist/providers/vercel/index.js +56 -0
  37. package/dist/providers/vercel/parser.js +239 -0
  38. package/dist/providers/vercel/stages.js +54 -0
  39. package/dist/tui/ansi.js +71 -0
  40. package/dist/tui/explore.js +220 -0
  41. package/dist/tui/panel.js +118 -0
  42. package/dist/tui/render.js +140 -0
  43. package/dist/tui/restore.js +29 -0
  44. package/dist/ui/app.js +332 -0
  45. package/dist/ui/index.html +60 -0
  46. package/dist/ui/styles.css +220 -0
  47. package/package.json +64 -4
  48. package/scripts/postinstall.mjs +13 -0
@@ -0,0 +1,220 @@
1
+ // Full-screen terminal explorer for a finished run (`unfold show`).
2
+ import { spawn } from 'node:child_process';
3
+ import { rankCauses } from '../core/fix-prompt.js';
4
+ import { padEnd, style, truncate, wrapText } from './ansi.js';
5
+ import { commandLabel, elapsed, flowLine, focusStage, glyph, outcomeLabel, STATUS_WORD } from './render.js';
6
+ import { onRestore } from './restore.js';
7
+ const stripStamp = (t) => t.replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z {0,2}/, '');
8
+ function rel(state, at) {
9
+ const sec = Math.max(0, (at - state.startedAt) / 1000);
10
+ return sec < 60 ? `+${sec.toFixed(1)}s` : `+${Math.floor(sec / 60)}m${Math.round(sec % 60)}s`;
11
+ }
12
+ function observed(m, st, w, s) {
13
+ const out = [s.bold('OBSERVED IN THIS RUN'), ''];
14
+ out.push(`${glyph(st, s)} ${s.bold(st.title)} ${s.dim('·')} ${STATUS_WORD[st.status]}`);
15
+ const note = st.status === 'pending' ? 'Nothing about this stage has appeared in the output yet.' : (st.note ?? '');
16
+ for (const l of wrapText(note, w))
17
+ out.push(st.status === 'failed' ? s.red(l) : st.status === 'unverified' ? s.yellow(l) : l);
18
+ if (st.status === 'failed') {
19
+ const fatal = m.state.errors.find((e) => e.severity === 'fatal' && e.stage === st.id);
20
+ const causes = rankCauses(m.state).filter((c) => c.stage === st.id).slice(0, 4);
21
+ out.push('');
22
+ const labelled = (label, text) => wrapText(text, w - 7).map((l, i) => (i ? ' ' : label.padEnd(7)) + l);
23
+ if (fatal)
24
+ out.push(...labelled('Error', `${fatal.message} (line ${fatal.seq})`).map((l, i) => (i ? l : s.red(l))));
25
+ for (const c of causes)
26
+ out.push(...labelled('Cause', `${c.message} (line ${c.seq})`).map((l, i) => (i ? l : s.yellow(l))));
27
+ }
28
+ const facts = m.state.facts.filter((f) => f.stage === st.id);
29
+ if (facts.length) {
30
+ out.push('');
31
+ const kw = Math.max(...facts.map((f) => f.label.length)) + 2;
32
+ for (const f of facts)
33
+ out.push(truncate(`${s.dim(f.label.padEnd(kw))}${f.href ? s.cyan(f.value) : f.value}`, w));
34
+ }
35
+ out.push('', s.dim('Evidence from the output'));
36
+ if (!st.evidence.length)
37
+ out.push(s.dim('No output from this run relates to this stage.'));
38
+ for (const e of st.evidence) {
39
+ const text = stripStamp(m.logs.find((l) => l.seq === e.seq)?.text ?? e.text);
40
+ out.push(`${s.dim(rel(m.state, e.at).padEnd(7))}${truncate(e.note, w - 7)}`);
41
+ out.push(` ${truncate(s.dim(`${text}`), w - 16)} ${s.dim(`line ${e.seq}`)}`);
42
+ }
43
+ return out;
44
+ }
45
+ function explanation(st, w, s) {
46
+ const out = [s.dim(s.bold('HOW THIS STAGE WORKS')) + s.dim(' · general, not from this run'), ''];
47
+ out.push(...wrapText(st.summary, w).map(s.bold), '');
48
+ for (const p of st.explanation)
49
+ out.push(...wrapText(p, w).map(s.dim), '');
50
+ out.push(...wrapText(`What would confirm it: ${st.confirmedBy}`, w));
51
+ if (st.docsUrl)
52
+ out.push('', s.cyan(truncate(st.docsUrl, w)));
53
+ return out;
54
+ }
55
+ function stageBody(m, cols, s) {
56
+ const st = m.state.stages[m.stage];
57
+ if (cols >= 120) {
58
+ const lw = Math.floor((cols - 5) * 0.58);
59
+ const rw = cols - 5 - lw;
60
+ const left = observed(m, st, lw, s);
61
+ const right = explanation(st, rw, s);
62
+ return Array.from({ length: Math.max(left.length, right.length) }, (_, i) => ` ${padEnd(truncate(left[i] ?? '', lw), lw)} ${truncate(right[i] ?? '', rw)}`);
63
+ }
64
+ const w = cols - 4;
65
+ return [...observed(m, st, w, s), '', ...explanation(st, w, s)].map((l) => ` ${truncate(l, w)}`);
66
+ }
67
+ function logsBody(m, cols, s) {
68
+ const errs = new Set(m.state.errors.map((e) => e.seq));
69
+ const ev = new Set(m.state.stages.flatMap((st) => st.evidence.map((e) => e.seq)));
70
+ const nw = String(m.logs.at(-1)?.seq ?? 0).length;
71
+ return m.logs.map((l) => {
72
+ const marker = errs.has(l.seq) ? s.red('▌') : ev.has(l.seq) ? s.green('▌') : ' ';
73
+ const body = truncate(l.text, cols - nw - 5);
74
+ const stamp = /^\d{4}-\d{2}-\d{2}T[\d:.]+Z/.exec(body)?.[0] ?? '';
75
+ const rest = body.slice(stamp.length);
76
+ const text = s.dim(stamp) + (errs.has(l.seq) ? s.red(rest) : rest);
77
+ const row = `${marker} ${s.dim(String(l.seq).padStart(nw))} ${text}`;
78
+ return l.seq === m.mark ? s.inverse(padEnd(row, cols - 1)) : row;
79
+ });
80
+ }
81
+ function promptBody(m, cols) {
82
+ if (!m.state.fix)
83
+ return [' No fix prompt: fix prompts are created when a run fails.'];
84
+ return m.state.fix.prompt.split('\n').flatMap((l) => (l ? wrapText(l, cols - 4) : [''])).map((l) => ` ${l}`);
85
+ }
86
+ export function body(m, cols, s) {
87
+ return m.view === 'logs' ? logsBody(m, cols, s) : m.view === 'prompt' ? promptBody(m, cols) : stageBody(m, cols, s);
88
+ }
89
+ /** Renders one full screen as exactly `rows` lines. */
90
+ export function screen(m, cols, rows, s = style(true)) {
91
+ const st = m.state;
92
+ const head = [
93
+ truncate(` ${s.bold('unfold')} ${s.dim('·')} ${outcomeLabel(st, s)} ${s.dim(`in ${elapsed(st)}`)}${st.mode !== 'live' ? s.dim(` · ${st.mode}`) : ''} ${s.dim('·')} ${commandLabel(st)}`, cols - 1),
94
+ truncate(` ${flowLine(st, s, { selected: m.view === 'stage' ? st.stages[m.stage].id : undefined })}`, cols - 1),
95
+ s.dim('─'.repeat(cols - 1)),
96
+ ];
97
+ const keys = m.view === 'stage'
98
+ ? '←/→ stage ↑/↓ scroll l logs e error f fix prompt c/x copy claude/codex command q quit'
99
+ : m.view === 'logs'
100
+ ? '↑/↓ PgUp/PgDn scroll e error l back q quit'
101
+ : '↑/↓ scroll c/x copy claude/codex command f back q quit';
102
+ const foot = [s.dim('─'.repeat(cols - 1)), truncate(m.message ? ` ${s.green(m.message)}` : s.dim(` ${keys}`), cols - 1)];
103
+ const avail = Math.max(1, rows - head.length - foot.length);
104
+ const lines = body(m, cols, s);
105
+ const maxScroll = Math.max(0, lines.length - avail);
106
+ m.scroll = Math.min(Math.max(0, m.scroll), maxScroll);
107
+ const visible = lines.slice(m.scroll, m.scroll + avail);
108
+ while (visible.length < avail)
109
+ visible.push('');
110
+ return [...head, ...visible, ...foot];
111
+ }
112
+ function copy(text) {
113
+ const [cmd, args] = process.platform === 'darwin' ? ['pbcopy', []] : process.platform === 'win32' ? ['clip', []] : ['xclip', ['-selection', 'clipboard']];
114
+ try {
115
+ const p = spawn(cmd, args, { stdio: ['pipe', 'ignore', 'ignore'] });
116
+ p.on('error', () => { });
117
+ p.stdin.end(text);
118
+ return true;
119
+ }
120
+ catch {
121
+ return false;
122
+ }
123
+ }
124
+ /** Interactive loop. Resolves when the user quits. */
125
+ export function explore(state, logs) {
126
+ const out = process.stdout;
127
+ const stdin = process.stdin;
128
+ const s = style(true);
129
+ const m = { state, logs: [...logs].sort((a, b) => a.seq - b.seq), stage: state.stages.indexOf(focusStage(state)), view: 'stage', scroll: 0 };
130
+ let msgTimer;
131
+ const draw = () => {
132
+ const rows = out.rows || 24;
133
+ const cols = out.columns || 80;
134
+ out.write('\x1b[H' + screen(m, cols, rows, s).map((l) => l + '\x1b[K').join('\r\n') + '\x1b[J');
135
+ };
136
+ const flash = (msg) => {
137
+ m.message = msg;
138
+ draw();
139
+ if (msgTimer)
140
+ clearTimeout(msgTimer);
141
+ msgTimer = setTimeout(() => ((m.message = undefined), draw()), 1800);
142
+ };
143
+ const jumpToError = () => {
144
+ const target = rankCauses(state)[0] ?? state.errors.find((e) => e.severity === 'fatal');
145
+ if (!target)
146
+ return flash('No errors in this run.');
147
+ m.view = 'logs';
148
+ m.mark = target.seq;
149
+ const page = Math.max(1, (out.rows || 24) - 5);
150
+ m.scroll = Math.max(0, m.logs.findIndex((l) => l.seq === target.seq) - Math.floor(page / 3));
151
+ draw();
152
+ };
153
+ const copyCmd = (agent) => {
154
+ const cmd = state.fix?.commands?.[agent];
155
+ if (!cmd)
156
+ return flash(state.fix ? 'The prompt was not saved, so there is no command to copy.' : 'Nothing to fix: fix prompts are created when a run fails.');
157
+ flash(copy(cmd) ? `Copied the ${agent === 'claude' ? 'Claude Code' : 'Codex'} command. Paste it in your project folder.` : cmd);
158
+ };
159
+ return new Promise((resolve) => {
160
+ const page = () => Math.max(1, (out.rows || 24) - 6);
161
+ const onKey = (buf) => {
162
+ const k = buf.toString();
163
+ if (k === 'q' || k === '\x1b' || k === '\x03')
164
+ return finish();
165
+ if (k === '\x1b[C' || k === '\t' || k === 'L')
166
+ ((m.stage = (m.stage + 1) % state.stages.length), (m.view = 'stage'), (m.scroll = 0));
167
+ else if (k === '\x1b[D' || k === '\x1b[Z' || k === 'H')
168
+ ((m.stage = (m.stage - 1 + state.stages.length) % state.stages.length), (m.view = 'stage'), (m.scroll = 0));
169
+ else if (k === '\x1b[B' || k === 'j')
170
+ m.scroll++;
171
+ else if (k === '\x1b[A' || k === 'k')
172
+ m.scroll--;
173
+ else if (k === '\x1b[6~' || k === ' ')
174
+ m.scroll += page();
175
+ else if (k === '\x1b[5~' || k === 'b')
176
+ m.scroll -= page();
177
+ else if (k === 'g')
178
+ m.scroll = 0;
179
+ else if (k === 'G')
180
+ m.scroll = 1e9;
181
+ else if (k === 'l')
182
+ ((m.view = m.view === 'logs' ? 'stage' : 'logs'), (m.scroll = m.view === 'logs' ? 1e9 : 0));
183
+ else if (k === 'f')
184
+ ((m.view = m.view === 'prompt' ? 'stage' : 'prompt'), (m.scroll = 0));
185
+ else if (k === 'e')
186
+ return jumpToError();
187
+ else if (k === 'c')
188
+ return copyCmd('claude');
189
+ else if (k === 'x')
190
+ return copyCmd('codex');
191
+ else if (/^[1-9]$/.test(k) && Number(k) <= state.stages.length)
192
+ ((m.stage = Number(k) - 1), (m.view = 'stage'), (m.scroll = 0));
193
+ draw();
194
+ };
195
+ const release = onRestore(() => {
196
+ if (stdin.isTTY)
197
+ stdin.setRawMode(false);
198
+ out.write('\x1b[?25h\x1b[?1049l');
199
+ });
200
+ const finish = () => {
201
+ release();
202
+ stdin.off('data', onKey);
203
+ out.off('resize', draw);
204
+ if (msgTimer)
205
+ clearTimeout(msgTimer);
206
+ if (stdin.isTTY)
207
+ stdin.setRawMode(false);
208
+ stdin.pause();
209
+ out.write('\x1b[?25h\x1b[?1049l');
210
+ resolve();
211
+ };
212
+ out.write('\x1b[?1049h\x1b[?25l');
213
+ if (stdin.isTTY)
214
+ stdin.setRawMode(true);
215
+ stdin.resume();
216
+ stdin.on('data', onKey);
217
+ out.on('resize', draw);
218
+ draw();
219
+ });
220
+ }
@@ -0,0 +1,118 @@
1
+ import { style, truncate, width } from './ansi.js';
2
+ import { elapsed, flowLine, focusStage, outcomeLabel } from './render.js';
3
+ import { onRestore } from './restore.js';
4
+ const HEIGHT = 3;
5
+ const ESC = '\x1b';
6
+ /** Whether the terminal can host the panel. */
7
+ export function panelSupported(out = process.stdout) {
8
+ return !!out.isTTY && process.env.TERM !== 'dumb' && (out.rows ?? 0) >= 12 && (out.columns ?? 0) >= 50 && !process.env.UNFOLD_NO_PANEL;
9
+ }
10
+ export class LivePanel {
11
+ opts;
12
+ out;
13
+ s;
14
+ state;
15
+ frame = 0;
16
+ timer;
17
+ active = false;
18
+ /** True while the last child chunk ended inside an escape sequence. */
19
+ midEscape = false;
20
+ dirty = false;
21
+ releaseRestore;
22
+ constructor(opts) {
23
+ this.opts = opts;
24
+ this.out = opts.out ?? process.stdout;
25
+ this.s = style(true);
26
+ }
27
+ /** Rows the wrapped command may use. */
28
+ get childRows() {
29
+ return Math.max(1, (this.out.rows || 24) - (this.active ? HEIGHT : 0));
30
+ }
31
+ start() {
32
+ if (this.active)
33
+ return;
34
+ this.active = true;
35
+ this.releaseRestore = onRestore(() => this.out.write(`${ESC}[r${ESC}[0m\r\n`));
36
+ // Make room: scroll existing content up so the panel doesn't cover it.
37
+ this.out.write('\n'.repeat(HEIGHT) + `${ESC}[${HEIGHT}A`);
38
+ this.setRegion();
39
+ this.draw();
40
+ this.timer = setInterval(() => {
41
+ this.frame++;
42
+ this.draw();
43
+ }, 120);
44
+ this.out.on('resize', this.onResize);
45
+ this.opts.onRowsChange?.(this.childRows);
46
+ }
47
+ update(state) {
48
+ this.state = state;
49
+ this.dirty = true;
50
+ }
51
+ /** Call after forwarding child output so we never draw in the middle of an escape sequence. */
52
+ afterChildOutput(chunk) {
53
+ const lastEsc = chunk.lastIndexOf(ESC);
54
+ if (lastEsc === -1) {
55
+ if (this.midEscape)
56
+ this.midEscape = !/[@-~]/.test(chunk);
57
+ }
58
+ else {
59
+ const tail = chunk.slice(lastEsc);
60
+ this.midEscape = !/^\x1b(\[[0-9;?]*[@-~]|\][^\x07]*(\x07|\x1b\\)|[^[\]])/.test(tail);
61
+ }
62
+ if (this.dirty)
63
+ this.draw();
64
+ }
65
+ /** Removes the panel and gives the whole screen back, leaving the cursor below the output. */
66
+ stop() {
67
+ if (!this.active)
68
+ return;
69
+ this.active = false;
70
+ if (this.timer)
71
+ clearInterval(this.timer);
72
+ this.out.off('resize', this.onResize);
73
+ const rows = this.out.rows || 24;
74
+ let seq = `${ESC}7`;
75
+ for (let r = rows - HEIGHT + 1; r <= rows; r++)
76
+ seq += `${ESC}[${r};1H${ESC}[2K`;
77
+ seq += `${ESC}[r${ESC}8\r\n`;
78
+ this.out.write(seq);
79
+ this.releaseRestore?.();
80
+ this.opts.onRowsChange?.(this.childRows);
81
+ }
82
+ onResize = () => {
83
+ this.setRegion();
84
+ this.draw();
85
+ this.opts.onRowsChange?.(this.childRows);
86
+ };
87
+ setRegion() {
88
+ // DECSTBM homes the cursor, so save and restore around it.
89
+ this.out.write(`${ESC}7${ESC}[1;${this.childRows}r${ESC}8`);
90
+ }
91
+ draw() {
92
+ if (!this.active || this.midEscape)
93
+ return;
94
+ this.dirty = false;
95
+ const rows = this.out.rows || 24;
96
+ const cols = this.out.columns || 80;
97
+ const lines = this.lines(cols);
98
+ let seq = `${ESC}7${ESC}[0m`;
99
+ lines.forEach((l, i) => (seq += `${ESC}[${rows - HEIGHT + 1 + i};1H${ESC}[2K${truncate(l, cols - 1)}`));
100
+ this.out.write(seq + `${ESC}8`);
101
+ }
102
+ lines(cols) {
103
+ const s = this.s;
104
+ const st = this.state;
105
+ // The status and timer always fit; the command title gives way.
106
+ const right = st ? ` ${outcomeLabel(st, s)} ${s.dim(elapsed(st))} ` : '';
107
+ const room = cols - 1 - 2 - width(right) - 2;
108
+ const label = truncate(` unfold · ${this.opts.title} `, Math.max(10, room));
109
+ const fill = Math.max(2, cols - 1 - 2 - width(label) - width(right));
110
+ const rule = s.dim('──') + s.bold(label) + s.dim('─'.repeat(fill)) + right;
111
+ if (!st)
112
+ return [rule, '', ''];
113
+ const focus = focusStage(st);
114
+ const last = focus.evidence.at(-1);
115
+ const detail = `${s.bold(focus.title)}${s.dim(':')} ${focus.note ?? focus.summary}${last && last.note !== focus.note ? s.dim(` · ${last.note}`) : ''}`;
116
+ return [rule, ` ${flowLine(st, s, { frame: this.frame })}`, ` ${detail}`];
117
+ }
118
+ }
@@ -0,0 +1,140 @@
1
+ // Pure renderers shared by the live panel, the end-of-run summary and the explorer.
2
+ import { rankCauses } from '../core/fix-prompt.js';
3
+ import { style as makeStyle, truncate, wrapText } from './ansi.js';
4
+ export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
5
+ export const STATUS_WORD = {
6
+ pending: 'waiting',
7
+ active: 'in progress',
8
+ confirmed: 'confirmed',
9
+ failed: 'failed',
10
+ unverified: 'unverified',
11
+ };
12
+ export function glyph(st, s, frame = 0) {
13
+ switch (st.status) {
14
+ case 'confirmed':
15
+ return s.green('✓');
16
+ case 'failed':
17
+ return s.red('✗');
18
+ case 'active':
19
+ return s.cyan(SPINNER[frame % SPINNER.length]);
20
+ case 'unverified':
21
+ return s.yellow('?');
22
+ default:
23
+ return s.dim('○');
24
+ }
25
+ }
26
+ const OUTCOME = { running: 'Running', success: 'Done', failure: 'Failed', interrupted: 'Stopped' };
27
+ export function outcomeLabel(state, s) {
28
+ if (state.outcome === 'success' && state.stages.at(-1)?.status !== 'confirmed')
29
+ return s.yellow('Exited 0, not confirmed');
30
+ const word = state.outcome === 'success' ? (state.notes?.success ?? OUTCOME.success) : OUTCOME[state.outcome];
31
+ return state.outcome === 'success' ? s.green(word) : state.outcome === 'failure' ? s.red(word) : state.outcome === 'interrupted' ? s.yellow(word) : s.cyan(word);
32
+ }
33
+ export function elapsed(state, now = Date.now()) {
34
+ const sec = Math.max(0, ((state.endedAt ?? now) - state.startedAt) / 1000);
35
+ return sec < 60 ? `${sec.toFixed(1)}s` : `${Math.floor(sec / 60)}m ${Math.round(sec % 60)}s`;
36
+ }
37
+ /** The stage most worth showing right now. */
38
+ export function focusStage(state) {
39
+ const st = state.stages;
40
+ if (state.failedStage)
41
+ return st.find((x) => x.id === state.failedStage);
42
+ const active = [...st].reverse().find((x) => x.status === 'active');
43
+ if (active)
44
+ return active;
45
+ if (state.outcome === 'success')
46
+ return st.at(-1);
47
+ const i = st.findIndex((x) => x.id === state.lastConfirmed);
48
+ if (state.outcome === 'interrupted')
49
+ return st[i + 1] ?? st[0];
50
+ return st[i] ?? st[0];
51
+ }
52
+ /** `✓ Prepare ── ✓ Upload ── ⠙ Build ── ○ Preview`, with an optional selected stage. */
53
+ export function flowLine(state, s, opts = {}) {
54
+ return state.stages
55
+ .map((st, i) => {
56
+ const prev = state.stages[i - 1];
57
+ const link = i === 0 ? '' : prev?.status === 'confirmed' ? s.green(' ── ') : s.dim(' ── ');
58
+ const title = st.id === opts.selected ? s.inverse(` ${st.title} `) : st.status === 'pending' ? s.dim(st.title) : st.status === 'failed' ? s.red(s.bold(st.title)) : s.bold(st.title);
59
+ return `${link}${glyph(st, s, opts.frame)} ${title}`;
60
+ })
61
+ .join('');
62
+ }
63
+ const stripStamp = (t) => t.replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z {0,2}/, '');
64
+ /** The command as shown in headers: executable base name plus arguments. */
65
+ export const commandLabel = (state) => [state.command[0]?.split(/[\\/]/).pop(), ...state.command.slice(1)].join(' ');
66
+ /** End-of-run summary printed in the terminal. */
67
+ export function summary(state, logs, opts = {}) {
68
+ const s = opts.s ?? makeStyle();
69
+ const cols = Math.max(40, Math.min(opts.cols ?? 100, 110));
70
+ const out = [];
71
+ const line = (t = '') => out.push(truncate(t, cols));
72
+ // Labelled text that must stay complete: wrapped under its label, never cut.
73
+ const block = (label, text, suffix = '') => {
74
+ const indent = 10;
75
+ wrapText(text + suffix, Math.max(20, cols - indent)).forEach((l, i) => out.push((i === 0 ? ` ${label}` : '').padEnd(i === 0 ? 0 : indent) + (i === 0 ? ' '.repeat(Math.max(1, indent - 2 - label.replace(/\x1b\[[0-9;]*m/g, '').length)) : '') + l));
76
+ };
77
+ const titleW = Math.max(...state.stages.map((x) => x.title.length)) + 2;
78
+ // Outcome first so it survives truncation; only the command gives way.
79
+ line(`${s.bold('unfold')} ${s.dim('·')} ${outcomeLabel(state, s)} ${s.dim(`in ${elapsed(state)}`)} ${s.dim('·')} ${commandLabel(state)}`);
80
+ line();
81
+ for (const st of state.stages) {
82
+ const note = st.note ?? st.summary;
83
+ const indent = 4 + titleW;
84
+ wrapText(note, Math.max(20, cols - indent)).forEach((l, i) => {
85
+ const text = st.status === 'unverified' || st.status === 'pending' ? s.dim(l) : l;
86
+ out.push(i === 0 ? ` ${glyph(st, s)} ${(st.status === 'failed' ? s.red : s.bold)(st.title.padEnd(titleW))}${text}` : ' '.repeat(indent) + text);
87
+ });
88
+ }
89
+ if (state.outcome === 'failure') {
90
+ const fatal = state.errors.find((e) => e.severity === 'fatal');
91
+ const cause = rankCauses(state)[0];
92
+ line();
93
+ if (fatal)
94
+ block(s.red('Error'), fatal.message, ` (line ${fatal.seq})`);
95
+ if (cause)
96
+ block(s.yellow('Cause'), cause.message, ` (line ${cause.seq})`);
97
+ const ref = logs
98
+ .map((l) => stripStamp(l.text))
99
+ .find((t) => /^\.{0,2}\/?[\w@.-]+(\/[\w@.[\]-]+)*\.[A-Za-z]{1,5}:\d+/.test(t.trim()));
100
+ if (ref)
101
+ line(` ${s.dim('File')} ${ref.trim()}`);
102
+ }
103
+ if (state.outcome === 'interrupted') {
104
+ line();
105
+ block(s.yellow('Stopped'), focusStage(state).note ?? 'Ctrl+C stopped the command before it finished.');
106
+ }
107
+ const highlights = (state.notes?.highlights ?? []).map((k) => state.facts.find((f) => f.key === k)).filter((f) => !!f);
108
+ if (highlights.length) {
109
+ line();
110
+ const kw = Math.max(...highlights.map((f) => f.label.length)) + 2;
111
+ for (const f of highlights)
112
+ wrapText(f.value, Math.max(20, cols - kw - 2)).forEach((l, n) => out.push(` ${n === 0 ? s.dim(f.label) + ' '.repeat(kw - f.label.length) : ' '.repeat(kw)}${l}`));
113
+ }
114
+ const url = state.facts.find((f) => f.key === 'alias') ?? state.facts.find((f) => f.key === 'deploymentUrl');
115
+ const inspect = state.facts.find((f) => f.key === 'inspectUrl' || f.key === 'logsUrl');
116
+ if (url || inspect)
117
+ line();
118
+ if (url && state.outcome === 'success')
119
+ line(` ${s.dim('URL'.padEnd(8))}${s.cyan(url.value)}`);
120
+ if (inspect)
121
+ out.push(` ${s.dim((inspect.key === 'logsUrl' ? 'Logs' : 'Inspect').padEnd(8))}${inspect.value}`);
122
+ if (state.fix?.commands) {
123
+ line();
124
+ line(` ${s.bold('Fix it with an agent')} ${s.dim('(prompt built from this run)')}`);
125
+ // Printed in full so they can be copied as-is.
126
+ out.push(` ${s.dim('Claude'.padEnd(8))}${state.fix.commands.claude}`);
127
+ out.push(` ${s.dim('Codex'.padEnd(8))}${state.fix.commands.codex}`);
128
+ }
129
+ // Output but nothing recognized: likely a CLI format Unfold doesn't know yet.
130
+ if (state.endedAt && state.lineCount > 3 && state.stages.every((x) => x.status === 'unverified' || x.status === 'pending')) {
131
+ const version = state.facts.find((f) => f.key === 'cliVersion');
132
+ line();
133
+ for (const l of wrapText(`Unfold didn't recognize any of this output${version ? ` (${version.label} ${version.value})` : ''}, so no stage is confirmed. If this was a normal run, please report it at https://github.com/faizm10/unfold/issues.`, cols - 4))
134
+ out.push(` ${s.yellow(l)}`);
135
+ }
136
+ const unverified = state.stages.filter((x) => x.status === 'unverified').length;
137
+ line();
138
+ line(s.dim(` ${unverified ? `? = the output never confirmed it. ` : ''}Explore: unfold show${opts.webUrl ? ` · Browser: ${opts.webUrl}` : ' · in a browser: unfold show --web'}`));
139
+ return out.join('\n') + '\n';
140
+ }
@@ -0,0 +1,29 @@
1
+ // Puts the terminal back the way we found it, even if Unfold crashes or is killed.
2
+ // Anything that changes terminal state (raw mode, scroll region, alternate screen)
3
+ // registers an undo here and removes it once it has cleaned up normally.
4
+ const undos = new Set();
5
+ let installed = false;
6
+ function restoreAll() {
7
+ for (const undo of [...undos].reverse()) {
8
+ try {
9
+ undo();
10
+ }
11
+ catch {
12
+ // keep restoring the rest
13
+ }
14
+ }
15
+ undos.clear();
16
+ }
17
+ /** Registers an undo; call the returned function after cleaning up normally. */
18
+ export function onRestore(undo) {
19
+ if (!installed) {
20
+ installed = true;
21
+ process.on('exit', restoreAll);
22
+ }
23
+ undos.add(undo);
24
+ return () => undos.delete(undo);
25
+ }
26
+ /** Restores the terminal now (used before printing a crash report). */
27
+ export function restoreTerminal() {
28
+ restoreAll();
29
+ }