@timurproko/a1 0.1.8-dev.447 → 0.1.8-dev.459
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 +9 -0
- package/dist/composition/owned-ui.js +12 -0
- package/dist/contracts/owned-ui/model.d.ts +12 -0
- package/dist/features/owned-ui/settings-app.d.ts +1 -0
- package/dist/features/owned-ui/settings-app.js +129 -17
- package/dist/integrations/pi/components/shell-editor-autocomplete.js +1 -0
- package/dist/integrations/pi/components/upstream/components/owned-editor.d.ts +2 -0
- package/dist/integrations/pi/components/upstream/components/owned-editor.js +17 -0
- package/dist/integrations/pi/engine/adapter.d.ts +2 -0
- package/dist/integrations/pi/engine/adapter.js +25 -6
- package/dist/integrations/pi/engine/settings-effects.d.ts +1 -1
- package/dist/integrations/pi/engine/settings-effects.js +1 -1
- package/dist/integrations/pi/session-ui/quit-outro-effects.d.ts +29 -0
- package/dist/integrations/pi/session-ui/quit-outro-effects.js +242 -0
- package/dist/integrations/pi/session-ui/quit-outro.d.ts +38 -0
- package/dist/integrations/pi/session-ui/quit-outro.js +98 -0
- package/dist/integrations/pi/session-ui/session-shell-root.d.ts +12 -1
- package/dist/integrations/pi/session-ui/session-shell.js +65 -5
- package/dist/integrations/pi/tui-runtime/adapter.d.ts +10 -2
- package/dist/integrations/pi/tui-runtime/adapter.js +53 -4
- package/dist/integrations/pi/tui-runtime/damage-aware-terminal.d.ts +7 -0
- package/dist/integrations/pi/tui-runtime/damage-aware-terminal.js +12 -0
- package/dist/native/darwin-arm64/manifest.json +1 -1
- package/dist/native/linux-x64/manifest.json +1 -1
- package/dist/native/win32-x64/manifest.json +2 -2
- package/dist/native/win32-x64/process-guardian.exe +0 -0
- package/dist/ui/components/list-view.js +5 -6
- package/dist/ui/components/surface.d.ts +7 -2
- package/dist/ui/components/surface.js +13 -3
- package/dist/ui/settings/declarations.d.ts +3 -1
- package/dist/ui/settings/declarations.js +33 -1
- package/dist/ui/settings/migrations.js +20 -0
- package/docs/architecture/ui-reference-provenance.md +2 -2
- package/docs/ci-release-runbook.md +5 -5
- package/docs/local-worktree-cleanup.md +40 -9
- package/docs/manual-owned-ui-checkpoint.md +3 -2
- package/docs/openspec-archive-automation.md +22 -9
- package/docs/validation.md +4 -2
- package/package.json +1 -1
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic quit-outro effect plans, ported from the v2 sketch.
|
|
3
|
+
*
|
|
4
|
+
* Each effect turns the captured frame's per-row visible widths and a seed into
|
|
5
|
+
* two sorted schedules over unit playback progress: sparkles paint a glyph at a
|
|
6
|
+
* cell from `start`, and clears blank a cell at `end`. The player consumes both
|
|
7
|
+
* in order, so a fixed seed yields a byte-stable animation.
|
|
8
|
+
*/
|
|
9
|
+
export const QUIT_OUTRO_EFFECTS = Object.freeze(["fall", "dissolve", "starburst", "waves"]);
|
|
10
|
+
export function isQuitOutroEffect(value) {
|
|
11
|
+
return typeof value === "string" && QUIT_OUTRO_EFFECTS.includes(value);
|
|
12
|
+
}
|
|
13
|
+
const WHITE = "\x1b[38;2;238;238;238m";
|
|
14
|
+
const DUST_GLYPHS = [".", "·", "'", ":", "+", "°"];
|
|
15
|
+
const FALL_GLYPHS = [".", ".", "·", "'", ":", "°"];
|
|
16
|
+
const GRAVITY = 85;
|
|
17
|
+
function mulberry32(seed) {
|
|
18
|
+
let value = seed >>> 0;
|
|
19
|
+
return () => {
|
|
20
|
+
value = (value + 0x6d2b79f5) | 0;
|
|
21
|
+
let next = Math.imul(value ^ (value >>> 15), 1 | value);
|
|
22
|
+
next = (next + Math.imul(next ^ (next >>> 7), 61 | next)) ^ next;
|
|
23
|
+
return ((next ^ (next >>> 14)) >>> 0) / 4294967296;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function clamp(value, min, max) {
|
|
27
|
+
return Math.max(min, Math.min(max, value));
|
|
28
|
+
}
|
|
29
|
+
function pick(random, values) {
|
|
30
|
+
return values[Math.floor(random() * values.length)];
|
|
31
|
+
}
|
|
32
|
+
function rowWidth(rowWidths, row) {
|
|
33
|
+
return Math.max(0, Math.floor(rowWidths[row] ?? 0));
|
|
34
|
+
}
|
|
35
|
+
function finish(sparkles, clears) {
|
|
36
|
+
sparkles.sort((a, b) => a.start - b.start);
|
|
37
|
+
clears.sort((a, b) => a.end - b.end);
|
|
38
|
+
return { sparkles, clears };
|
|
39
|
+
}
|
|
40
|
+
/** Calm white ASCII dust dissolve. */
|
|
41
|
+
function dissolvePlan(rowWidths, seed) {
|
|
42
|
+
const random = mulberry32(seed);
|
|
43
|
+
const height = Math.max(1, rowWidths.length);
|
|
44
|
+
const width = Math.max(1, ...rowWidths);
|
|
45
|
+
const sparkles = [];
|
|
46
|
+
const clears = [];
|
|
47
|
+
for (let row = 0; row < rowWidths.length; row++) {
|
|
48
|
+
for (let col = 0; col < rowWidth(rowWidths, row); col++) {
|
|
49
|
+
const sweep = (row / height) * 0.14 + (col / width) * 0.08;
|
|
50
|
+
const start = Math.min(0.76, sweep + random() * 0.54);
|
|
51
|
+
const cell = {
|
|
52
|
+
row, col, start,
|
|
53
|
+
end: Math.min(0.96, start + 0.1 + random() * 0.1),
|
|
54
|
+
glyph: pick(random, DUST_GLYPHS),
|
|
55
|
+
color: WHITE,
|
|
56
|
+
};
|
|
57
|
+
clears.push(cell);
|
|
58
|
+
if (random() < 0.34)
|
|
59
|
+
sparkles.push(cell);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return finish(sparkles, clears);
|
|
63
|
+
}
|
|
64
|
+
/** Soft white harmonic ripple dissolve. */
|
|
65
|
+
function starburstPlan(rowWidths, seed) {
|
|
66
|
+
const random = mulberry32(seed);
|
|
67
|
+
const height = Math.max(1, rowWidths.length);
|
|
68
|
+
const width = Math.max(1, ...rowWidths);
|
|
69
|
+
const center = (width - 1) / 2;
|
|
70
|
+
const sparkles = [];
|
|
71
|
+
const clears = [];
|
|
72
|
+
for (let row = 0; row < rowWidths.length; row++) {
|
|
73
|
+
for (let col = 0; col < rowWidth(rowWidths, row); col++) {
|
|
74
|
+
const distance = Math.abs(col - center) / Math.max(1, width / 2);
|
|
75
|
+
const ripple = (Math.sin(distance * Math.PI * 3 + (row / height) * Math.PI) + 1) * 0.035;
|
|
76
|
+
const sweep = distance * 0.2 + (row / height) * 0.1 + ripple;
|
|
77
|
+
const start = Math.min(0.79, sweep + random() * 0.34);
|
|
78
|
+
const cell = {
|
|
79
|
+
row, col, start,
|
|
80
|
+
end: Math.min(0.97, start + 0.12 + random() * 0.1),
|
|
81
|
+
glyph: pick(random, DUST_GLYPHS),
|
|
82
|
+
color: WHITE,
|
|
83
|
+
};
|
|
84
|
+
clears.push(cell);
|
|
85
|
+
if (random() < 0.38)
|
|
86
|
+
sparkles.push(cell);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return finish(sparkles, clears);
|
|
90
|
+
}
|
|
91
|
+
/** White dotted fragments accelerating downward and collecting in a floor pile. */
|
|
92
|
+
function fallPlan(rowWidths, seed) {
|
|
93
|
+
const random = mulberry32(seed);
|
|
94
|
+
const height = Math.max(1, rowWidths.length);
|
|
95
|
+
const width = Math.max(1, ...rowWidths);
|
|
96
|
+
const floor = height - 1;
|
|
97
|
+
const area = Math.max(1, width * height);
|
|
98
|
+
const fragmentChance = clamp(150 / area, 0.1, 0.24);
|
|
99
|
+
const particles = [];
|
|
100
|
+
const sparkles = [];
|
|
101
|
+
const clears = [];
|
|
102
|
+
let fallbackParticle;
|
|
103
|
+
for (let row = 0; row < rowWidths.length; row++) {
|
|
104
|
+
for (let col = 0; col < rowWidth(rowWidths, row); col++) {
|
|
105
|
+
const release = 0.025 + random() * 0.13 + (1 - row / height) * 0.035;
|
|
106
|
+
const glyph = pick(random, FALL_GLYPHS);
|
|
107
|
+
clears.push({ row, col, start: release, end: release + 0.04, glyph, color: WHITE });
|
|
108
|
+
if (row >= floor)
|
|
109
|
+
continue;
|
|
110
|
+
const particle = { row, col, release, glyph, velocityCol: (random() - 0.5) * 9 };
|
|
111
|
+
fallbackParticle ??= particle;
|
|
112
|
+
if (random() < fragmentChance)
|
|
113
|
+
particles.push(particle);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (particles.length === 0 && fallbackParticle)
|
|
117
|
+
particles.push(fallbackParticle);
|
|
118
|
+
const arrival = (particle) => particle.release + Math.sqrt((2 * (floor - particle.row)) / GRAVITY);
|
|
119
|
+
particles.sort((a, b) => arrival(a) - arrival(b));
|
|
120
|
+
const pileHeights = Array(width).fill(0);
|
|
121
|
+
for (const particle of particles) {
|
|
122
|
+
const floorFallTime = Math.max(0.05, Math.sqrt((2 * (floor - particle.row)) / GRAVITY));
|
|
123
|
+
const predictedCol = clamp(Math.round(particle.col + particle.velocityCol * floorFallTime), 0, width - 1);
|
|
124
|
+
let landingCol = predictedCol;
|
|
125
|
+
for (let radius = 1; radius <= 3; radius++) {
|
|
126
|
+
for (const candidate of [predictedCol - radius, predictedCol + radius]) {
|
|
127
|
+
if (candidate >= 0 && candidate < width && pileHeights[candidate] < pileHeights[landingCol])
|
|
128
|
+
landingCol = candidate;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const stackHeight = pileHeights[landingCol]++;
|
|
132
|
+
const restingRow = clamp(floor - stackHeight, particle.row + 1, floor);
|
|
133
|
+
const fallDistance = Math.max(1, restingRow - particle.row);
|
|
134
|
+
const fallTime = Math.sqrt((2 * fallDistance) / GRAVITY);
|
|
135
|
+
const velocityCol = (landingCol - particle.col) / fallTime;
|
|
136
|
+
const steps = Math.max(4, Math.min(14, Math.ceil(fallTime / 0.045)));
|
|
137
|
+
const interval = fallTime / steps;
|
|
138
|
+
for (let step = 1; step < steps; step++) {
|
|
139
|
+
const elapsed = step * interval;
|
|
140
|
+
const progress = step / steps;
|
|
141
|
+
const row = particle.row + 0.5 * GRAVITY * elapsed * elapsed;
|
|
142
|
+
const col = particle.col + velocityCol * elapsed + Math.sin(progress * Math.PI) * 0.3;
|
|
143
|
+
const start = clamp(particle.release + elapsed, 0, 0.93);
|
|
144
|
+
const cell = {
|
|
145
|
+
row: clamp(Math.round(row), 0, floor),
|
|
146
|
+
col: clamp(Math.round(col), 0, width - 1),
|
|
147
|
+
start,
|
|
148
|
+
end: Math.min(0.96, start + Math.max(0.025, interval * 0.72)),
|
|
149
|
+
glyph: particle.glyph,
|
|
150
|
+
color: WHITE,
|
|
151
|
+
};
|
|
152
|
+
sparkles.push(cell);
|
|
153
|
+
clears.push(cell);
|
|
154
|
+
}
|
|
155
|
+
const settledAt = clamp(particle.release + fallTime, 0, 0.93);
|
|
156
|
+
const settled = { row: restingRow, col: landingCol, start: settledAt, end: 0.97, glyph: particle.glyph, color: WHITE };
|
|
157
|
+
sparkles.push(settled);
|
|
158
|
+
clears.push(settled);
|
|
159
|
+
}
|
|
160
|
+
return finish(sparkles, clears);
|
|
161
|
+
}
|
|
162
|
+
/** Soft white dotted radio pulses expanding from a gently moving center. */
|
|
163
|
+
function wavesPlan(rowWidths, seed) {
|
|
164
|
+
const random = mulberry32(seed);
|
|
165
|
+
const height = Math.max(1, rowWidths.length);
|
|
166
|
+
const width = Math.max(1, ...rowWidths);
|
|
167
|
+
const baseCenterCol = (width - 1) / 2;
|
|
168
|
+
const baseCenterRow = (height - 1) / 2;
|
|
169
|
+
const sparkles = [];
|
|
170
|
+
const clears = [];
|
|
171
|
+
const addPulseCell = (row, col, start, lifetime, glyph) => {
|
|
172
|
+
const safeStart = clamp(start, 0, 0.94);
|
|
173
|
+
const cell = {
|
|
174
|
+
row: clamp(Math.round(row), 0, height - 1),
|
|
175
|
+
col: clamp(Math.round(col), 0, width - 1),
|
|
176
|
+
start: safeStart,
|
|
177
|
+
end: clamp(safeStart + lifetime, safeStart + 0.02, 0.98),
|
|
178
|
+
glyph,
|
|
179
|
+
color: WHITE,
|
|
180
|
+
};
|
|
181
|
+
sparkles.push(cell);
|
|
182
|
+
clears.push(cell);
|
|
183
|
+
};
|
|
184
|
+
// Rationale: clear the frame in the same outward direction as the expanding signal.
|
|
185
|
+
for (let row = 0; row < rowWidths.length; row++) {
|
|
186
|
+
for (let col = 0; col < rowWidth(rowWidths, row); col++) {
|
|
187
|
+
const x = (col - baseCenterCol) / Math.max(1, width * 0.5);
|
|
188
|
+
const y = (row - baseCenterRow) / Math.max(1, height * 0.5);
|
|
189
|
+
const distance = Math.min(1.25, Math.sqrt(x * x + y * y));
|
|
190
|
+
const start = 0.04 + random() * 0.1;
|
|
191
|
+
const cell = {
|
|
192
|
+
row, col, start,
|
|
193
|
+
end: Math.min(0.95, 0.18 + distance * 0.54 + random() * 0.12),
|
|
194
|
+
glyph: pick(random, DUST_GLYPHS),
|
|
195
|
+
color: WHITE,
|
|
196
|
+
};
|
|
197
|
+
clears.push(cell);
|
|
198
|
+
if (random() < 0.055)
|
|
199
|
+
sparkles.push(cell);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const pulseCount = 4;
|
|
203
|
+
const expansionSteps = 9;
|
|
204
|
+
for (let pulse = 0; pulse < pulseCount; pulse++) {
|
|
205
|
+
const pulseStart = 0.025 + pulse * 0.135;
|
|
206
|
+
const phase = pulse * 1.47 + random() * 0.45;
|
|
207
|
+
for (let step = 0; step <= expansionSteps; step++) {
|
|
208
|
+
const progress = step / expansionSteps;
|
|
209
|
+
const centerCol = baseCenterCol + Math.sin(phase + progress * Math.PI) * width * 0.035;
|
|
210
|
+
const centerRow = baseCenterRow + Math.cos(phase + progress * Math.PI * 0.8) * height * 0.065;
|
|
211
|
+
const radiusCol = progress * width * 0.56;
|
|
212
|
+
const radiusRow = progress * height * 0.56;
|
|
213
|
+
const samples = Math.max(10, Math.min(58, Math.round(12 + radiusCol * 0.48)));
|
|
214
|
+
const time = pulseStart + progress * 0.28;
|
|
215
|
+
if (step === 0) {
|
|
216
|
+
addPulseCell(centerRow, centerCol, time, 0.1, pulse % 2 === 0 ? "+" : "°");
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
for (let sample = 0; sample < samples; sample++) {
|
|
220
|
+
if (random() < 0.08 + progress * 0.06)
|
|
221
|
+
continue;
|
|
222
|
+
const angle = (sample / samples) * Math.PI * 2 + Math.sin(phase) * 0.035;
|
|
223
|
+
const shimmer = (random() - 0.5) * (0.25 + progress * 0.55);
|
|
224
|
+
const row = centerRow + Math.sin(angle) * radiusRow + shimmer * 0.4;
|
|
225
|
+
const col = centerCol + Math.cos(angle) * radiusCol + shimmer;
|
|
226
|
+
const glyph = progress < 0.28 ? "°" : progress < 0.62 ? "·" : random() < 0.72 ? "." : "'";
|
|
227
|
+
addPulseCell(row, col, time + random() * 0.018, 0.065 + (1 - progress) * 0.055, glyph);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return finish(sparkles, clears);
|
|
232
|
+
}
|
|
233
|
+
const PLANS = Object.freeze({
|
|
234
|
+
fall: fallPlan,
|
|
235
|
+
dissolve: dissolvePlan,
|
|
236
|
+
starburst: starburstPlan,
|
|
237
|
+
waves: wavesPlan,
|
|
238
|
+
});
|
|
239
|
+
/** Builds the selected effect's deterministic plan for the captured row widths. */
|
|
240
|
+
export function createQuitOutroPlan(effect, rowWidths, seed) {
|
|
241
|
+
return PLANS[effect](rowWidths, seed);
|
|
242
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type QuitOutroEffect } from "./quit-outro-effects.js";
|
|
2
|
+
export interface QuitOutroFrame {
|
|
3
|
+
/** Row content as presented, truncated to `columns`; index 0 is the top row. */
|
|
4
|
+
readonly lines: readonly string[];
|
|
5
|
+
readonly columns: number;
|
|
6
|
+
readonly rows: number;
|
|
7
|
+
/** Visible width of each line, at most `columns`. */
|
|
8
|
+
readonly rowWidths: readonly number[];
|
|
9
|
+
}
|
|
10
|
+
export interface QuitOutroPlayback {
|
|
11
|
+
write(data: string): void;
|
|
12
|
+
/** Monotonic clock seam; defaults to `Date.now`. */
|
|
13
|
+
now?(): number;
|
|
14
|
+
/** Delay seam; defaults to a timer. */
|
|
15
|
+
sleep?(ms: number): Promise<void>;
|
|
16
|
+
/** Plan seed; defaults to a time- and geometry-derived value. */
|
|
17
|
+
readonly seed?: number;
|
|
18
|
+
}
|
|
19
|
+
export declare const QUIT_OUTRO_MIN_MS = 300;
|
|
20
|
+
export declare const QUIT_OUTRO_MAX_MS = 2000;
|
|
21
|
+
/** Wall-clock allowance beyond the clamped duration before playback abandons remaining ticks. */
|
|
22
|
+
export declare const QUIT_OUTRO_GUARD_MS = 500;
|
|
23
|
+
/** Tick ceiling for a clock that stops advancing: the longest playback plus its guard at 30 fps. */
|
|
24
|
+
export declare const QUIT_OUTRO_MAX_TICKS: number;
|
|
25
|
+
export declare function clampQuitOutroDuration(durationMs: number): number;
|
|
26
|
+
/**
|
|
27
|
+
* Captures the presented rows into an outro frame. Returns null when nothing is
|
|
28
|
+
* visible, so a blank screen never animates.
|
|
29
|
+
*/
|
|
30
|
+
export declare function captureQuitOutroFrame(presented: readonly string[], columns: number, rows: number): QuitOutroFrame | null;
|
|
31
|
+
/** The clear-and-repaint block that seeds the animation surface with the captured frame. */
|
|
32
|
+
export declare function createQuitOutroSurfaceFrame(frame: QuitOutroFrame): string;
|
|
33
|
+
/**
|
|
34
|
+
* Plays the effect over the frame. Resolves true when the plan was played to
|
|
35
|
+
* completion or abandoned at its guard, false when there was nothing to play.
|
|
36
|
+
* Write failures propagate so the caller can decide to continue restoration.
|
|
37
|
+
*/
|
|
38
|
+
export declare function playQuitOutro(frame: QuitOutroFrame, effect: QuitOutroEffect, durationMs: number, playback: QuitOutroPlayback): Promise<boolean>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bare A1's quit outro: the last presented fullscreen frame is captured, the
|
|
3
|
+
* selected effect animates over it on the alternate screen, and only then does
|
|
4
|
+
* the shell leave that screen. Every tick is one synchronized-output block so a
|
|
5
|
+
* terminal never shows a half-painted step, and playback is bounded so a slow
|
|
6
|
+
* terminal cannot delay restoration past the configured clamp.
|
|
7
|
+
*/
|
|
8
|
+
import { displayWidth, truncateToWidth } from "../../../ui/components/index.js";
|
|
9
|
+
import { createQuitOutroPlan } from "./quit-outro-effects.js";
|
|
10
|
+
export const QUIT_OUTRO_MIN_MS = 300;
|
|
11
|
+
export const QUIT_OUTRO_MAX_MS = 2000;
|
|
12
|
+
/** Wall-clock allowance beyond the clamped duration before playback abandons remaining ticks. */
|
|
13
|
+
export const QUIT_OUTRO_GUARD_MS = 500;
|
|
14
|
+
const FRAME_MS = 1000 / 30;
|
|
15
|
+
/** Tick ceiling for a clock that stops advancing: the longest playback plus its guard at 30 fps. */
|
|
16
|
+
export const QUIT_OUTRO_MAX_TICKS = Math.ceil((QUIT_OUTRO_MAX_MS + QUIT_OUTRO_GUARD_MS) / FRAME_MS) + 1;
|
|
17
|
+
const SYNC_BEGIN = "\x1b[?2026h";
|
|
18
|
+
const SYNC_END = "\x1b[?2026l";
|
|
19
|
+
const RESET = "\x1b[0m";
|
|
20
|
+
const CLEAR_SCREEN = "\x1b[2J";
|
|
21
|
+
const HOME = "\x1b[H";
|
|
22
|
+
const HIDE_CURSOR = "\x1b[?25l";
|
|
23
|
+
export function clampQuitOutroDuration(durationMs) {
|
|
24
|
+
if (!Number.isFinite(durationMs))
|
|
25
|
+
return QUIT_OUTRO_MIN_MS;
|
|
26
|
+
return Math.max(QUIT_OUTRO_MIN_MS, Math.min(QUIT_OUTRO_MAX_MS, Math.floor(durationMs)));
|
|
27
|
+
}
|
|
28
|
+
function cursorAt(row, col) {
|
|
29
|
+
return `\x1b[${row + 1};${col + 1}H`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Captures the presented rows into an outro frame. Returns null when nothing is
|
|
33
|
+
* visible, so a blank screen never animates.
|
|
34
|
+
*/
|
|
35
|
+
export function captureQuitOutroFrame(presented, columns, rows) {
|
|
36
|
+
const width = Math.max(1, Math.floor(columns));
|
|
37
|
+
const height = Math.max(1, Math.floor(rows));
|
|
38
|
+
const lines = presented.slice(0, height).map(line => truncateToWidth(line, width));
|
|
39
|
+
while (lines.length < height)
|
|
40
|
+
lines.push("");
|
|
41
|
+
const rowWidths = lines.map(line => Math.min(width, displayWidth(line)));
|
|
42
|
+
if (rowWidths.every(w => w === 0))
|
|
43
|
+
return null;
|
|
44
|
+
return { lines, columns: width, rows: height, rowWidths };
|
|
45
|
+
}
|
|
46
|
+
/** The clear-and-repaint block that seeds the animation surface with the captured frame. */
|
|
47
|
+
export function createQuitOutroSurfaceFrame(frame) {
|
|
48
|
+
let output = `${SYNC_BEGIN}${HIDE_CURSOR}${CLEAR_SCREEN}${HOME}${RESET}`;
|
|
49
|
+
for (let row = 0; row < frame.rows; row++) {
|
|
50
|
+
const line = frame.lines[row] ?? "";
|
|
51
|
+
if (frame.rowWidths[row] > 0)
|
|
52
|
+
output += `${cursorAt(row, 0)}${line}${RESET}`;
|
|
53
|
+
}
|
|
54
|
+
return `${output}${SYNC_END}`;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Plays the effect over the frame. Resolves true when the plan was played to
|
|
58
|
+
* completion or abandoned at its guard, false when there was nothing to play.
|
|
59
|
+
* Write failures propagate so the caller can decide to continue restoration.
|
|
60
|
+
*/
|
|
61
|
+
export async function playQuitOutro(frame, effect, durationMs, playback) {
|
|
62
|
+
const now = playback.now ?? (() => Date.now());
|
|
63
|
+
const sleep = playback.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
|
|
64
|
+
const seed = playback.seed ?? (Date.now() ^ (frame.columns << 16) ^ frame.rows);
|
|
65
|
+
const plan = createQuitOutroPlan(effect, frame.rowWidths, seed);
|
|
66
|
+
if (plan.clears.length === 0)
|
|
67
|
+
return false;
|
|
68
|
+
const duration = clampQuitOutroDuration(durationMs);
|
|
69
|
+
let sparkleIndex = 0;
|
|
70
|
+
let clearIndex = 0;
|
|
71
|
+
let ticks = 0;
|
|
72
|
+
playback.write(createQuitOutroSurfaceFrame(frame));
|
|
73
|
+
const startedAt = now();
|
|
74
|
+
for (;;) {
|
|
75
|
+
ticks += 1;
|
|
76
|
+
const elapsed = now() - startedAt;
|
|
77
|
+
const progress = Math.min(1, elapsed / duration);
|
|
78
|
+
let output = `${SYNC_BEGIN}${RESET}`;
|
|
79
|
+
while (sparkleIndex < plan.sparkles.length && plan.sparkles[sparkleIndex].start <= progress) {
|
|
80
|
+
const cell = plan.sparkles[sparkleIndex++];
|
|
81
|
+
output += `${cursorAt(cell.row, cell.col)}${cell.color}${cell.glyph}`;
|
|
82
|
+
}
|
|
83
|
+
while (clearIndex < plan.clears.length && plan.clears[clearIndex].end <= progress) {
|
|
84
|
+
const cell = plan.clears[clearIndex++];
|
|
85
|
+
output += `${cursorAt(cell.row, cell.col)}${RESET} `;
|
|
86
|
+
}
|
|
87
|
+
const finished = clearIndex >= plan.clears.length || progress >= 1
|
|
88
|
+
|| elapsed > duration + QUIT_OUTRO_GUARD_MS || ticks >= QUIT_OUTRO_MAX_TICKS;
|
|
89
|
+
// Invariant: the last block leaves the alternate screen blank regardless of which
|
|
90
|
+
// cells the plan reached, so the leave never reveals a half-cleared frame.
|
|
91
|
+
if (finished)
|
|
92
|
+
output += `${CLEAR_SCREEN}${HOME}`;
|
|
93
|
+
playback.write(`${output}${RESET}${SYNC_END}`);
|
|
94
|
+
if (finished)
|
|
95
|
+
return true;
|
|
96
|
+
await sleep(FRAME_MS);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { OwnedUiPromptSuggestionGeneratorPort, OwnedUiSessionViewModel, OwnedUiViewportSettings, OwnedUiViewportSettingsPort, SuggestionDecision, SuggestionDiagnosticObserver } from "../../../contracts/owned-ui/index.js";
|
|
1
|
+
import type { OwnedUiPromptSuggestionGeneratorPort, OwnedUiSessionViewModel, OwnedUiViewportSettings, OwnedUiViewportSettingsPort, OwnedUiQuitOutroSettingsPort, SuggestionDecision, SuggestionDiagnosticObserver } from "../../../contracts/owned-ui/index.js";
|
|
2
2
|
import type { PiTuiPointerSurface } from "../tui-runtime/contracts.js";
|
|
3
3
|
import type { UiRouteHost } from "../../../ui/apps/contracts.js";
|
|
4
4
|
import type { TranscriptViewportFrame, TranscriptViewportFrameDescriptor } from "../../../ui/components/transcript-viewport.js";
|
|
@@ -47,6 +47,17 @@ export interface OwnedUiSessionShellOptions {
|
|
|
47
47
|
readonly sessionLayout?: "pinned" | "custom-viewport";
|
|
48
48
|
/** Live profile-local settings, supplied only to the bare-A1 composition. */
|
|
49
49
|
readonly viewportSettings?: OwnedUiViewportSettingsPort;
|
|
50
|
+
/**
|
|
51
|
+
* Quit outro settings and seams, supplied only to the bare-A1 composition.
|
|
52
|
+
* `interactive` states whether the terminal can show the animation; production
|
|
53
|
+
* passes stdout's TTY state, tests opt in explicitly.
|
|
54
|
+
*/
|
|
55
|
+
readonly quitOutro?: OwnedUiQuitOutroSettingsPort & {
|
|
56
|
+
readonly interactive: boolean;
|
|
57
|
+
readonly now?: () => number;
|
|
58
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
59
|
+
readonly seed?: number;
|
|
60
|
+
};
|
|
50
61
|
/** Optional platform seam; production uses A1's system clipboard adapter. */
|
|
51
62
|
readonly clipboard?: OwnedUiClipboardPort;
|
|
52
63
|
/** Owned response-copy transport and payload-free diagnostic seams. Comparison profiles ignore them. */
|
|
@@ -55,6 +55,7 @@ export class OwnedUiSessionShell {
|
|
|
55
55
|
#responseCopy;
|
|
56
56
|
#unbindClipboardWriter;
|
|
57
57
|
#damageTerminal;
|
|
58
|
+
#quitOutro;
|
|
58
59
|
#streamPresentation;
|
|
59
60
|
#removeViewportPreInput;
|
|
60
61
|
#unsubscribeSettings;
|
|
@@ -250,6 +251,7 @@ export class OwnedUiSessionShell {
|
|
|
250
251
|
runtime = new PiTuiRuntimeAdapter(runtimeOptions);
|
|
251
252
|
this.runtime = runtime;
|
|
252
253
|
this.#damageTerminal = damageTerminal ?? null;
|
|
254
|
+
this.#quitOutro = options.quitOutro;
|
|
253
255
|
const presentationInterval = options.streamPresentation?.intervalMs ?? STREAM_PRESENTATION_INTERVAL_MS;
|
|
254
256
|
streamPresentation = options.streamPresentation?.scheduler === undefined
|
|
255
257
|
? new StreamPresentationCoalescer(() => this.runtime.requestRender(), presentationInterval)
|
|
@@ -282,7 +284,9 @@ export class OwnedUiSessionShell {
|
|
|
282
284
|
this.runtime.setClearOnShrink(initialPiSettings.clearOnShrink);
|
|
283
285
|
this.#terminalProgressEnabled = initialPiSettings.showTerminalProgress;
|
|
284
286
|
this.#fullscreenExitOutput = initialPiSettings.fullscreenExitOutput;
|
|
285
|
-
|
|
287
|
+
// Invariant: bare A1 prints only the resume hint at exit, so the pinned exit-output
|
|
288
|
+
// choice is hidden there and cannot be bound; the comparison profile binds and honors it.
|
|
289
|
+
this.#unbindShutdownSettings = this.backend.settingsProductMode === "bare" ? () => { } : this.backend.bindSettingsOwner("shutdown", {
|
|
286
290
|
fullscreenExitOutput: { apply() { } },
|
|
287
291
|
});
|
|
288
292
|
this.#unbindTerminalSettings = this.backend.bindSettingsOwner("terminal", {
|
|
@@ -1350,6 +1354,8 @@ export class OwnedUiSessionShell {
|
|
|
1350
1354
|
}
|
|
1351
1355
|
async #dispose() {
|
|
1352
1356
|
this.#disposed = true;
|
|
1357
|
+
// Invariant: the outro frame is what the terminal shows now, before any cleanup writes.
|
|
1358
|
+
const outroFrame = this.#captureQuitOutroFrame();
|
|
1353
1359
|
this.#responseCopy?.dispose();
|
|
1354
1360
|
this.#cancelWaitingImages();
|
|
1355
1361
|
const failures = [];
|
|
@@ -1373,11 +1379,13 @@ export class OwnedUiSessionShell {
|
|
|
1373
1379
|
let fullscreenExitText = "";
|
|
1374
1380
|
attempt(() => {
|
|
1375
1381
|
const exitMode = this.backend.disposed ? this.#fullscreenExitOutput : this.backend.pinnedSettingsSnapshot().fullscreenExitOutput;
|
|
1376
|
-
const exitTranscript = this.root.exitTranscript(this.runtime.viewport().columns);
|
|
1377
1382
|
const resume = this.backend.currentSessionResumeMetadata();
|
|
1378
1383
|
const resumeHint = resume === null ? "" : `${dim("To resume this session:")} ${formatSessionResumeCommand(resume)}`;
|
|
1384
|
+
// Invariant: bare A1 leaves only the hint behind; the pinned comparison profile still
|
|
1385
|
+
// honors fullscreenExitOutput, including the styled transcript.
|
|
1379
1386
|
fullscreenExitText = this.runtime.mode !== "fullscreen" ? ""
|
|
1380
|
-
: exitMode === "resume-hint" ? resumeHint
|
|
1387
|
+
: this.#customViewport || exitMode === "resume-hint" ? resumeHint
|
|
1388
|
+
: [this.root.exitTranscript(this.runtime.viewport().columns), resumeHint].filter(Boolean).join("\n\n");
|
|
1381
1389
|
});
|
|
1382
1390
|
attempt(() => this.#unbindClipboardWriter());
|
|
1383
1391
|
attempt(() => this.#unbindPiSettings());
|
|
@@ -1386,8 +1394,15 @@ export class OwnedUiSessionShell {
|
|
|
1386
1394
|
attempt(() => this.#unsubscribe());
|
|
1387
1395
|
attempt(() => this.#dialogHandle?.hide());
|
|
1388
1396
|
attempt(() => this.#extensionBridge.dispose());
|
|
1389
|
-
// Invariant:
|
|
1390
|
-
|
|
1397
|
+
// Invariant: from here to the leave nothing but the outro paints. A throttled frame the
|
|
1398
|
+
// renderer still has queued would otherwise land during the stop-time input drain and
|
|
1399
|
+
// flash the prompt and footer, whether or not an effect plays.
|
|
1400
|
+
attempt(() => this.#freezeQuitPresentation());
|
|
1401
|
+
await this.#playQuitOutro(outroFrame);
|
|
1402
|
+
// Invariant: terminal restoration precedes any potentially stalled backend teardown. The
|
|
1403
|
+
// fullscreen leave preserves the screen: the pinned runtime never dumps its final document
|
|
1404
|
+
// into the parent terminal, so only the configured exit text follows the leave.
|
|
1405
|
+
await this.runtime.dispose({ preserveScreen: this.runtime.mode === "fullscreen" }).catch(error => failures.push(error));
|
|
1391
1406
|
await historyCleanup.catch(() => false); // Security: background durability outcomes never enter terminal output.
|
|
1392
1407
|
await boundedCleanup(() => pasteCleanup).catch(error => failures.push(error));
|
|
1393
1408
|
await boundedCleanup(() => this.backend.unbindExtensionUi()).catch(error => failures.push(error));
|
|
@@ -1396,6 +1411,51 @@ export class OwnedUiSessionShell {
|
|
|
1396
1411
|
if (fullscreenExitText.length > 0)
|
|
1397
1412
|
this.runtime.writeAfterStop(`${fullscreenExitText}\n`);
|
|
1398
1413
|
}
|
|
1414
|
+
// Invariant: the snapshot is synchronous; the outro module itself loads only at quit.
|
|
1415
|
+
#captureQuitOutroFrame() {
|
|
1416
|
+
const outro = this.#quitOutro;
|
|
1417
|
+
if (outro === undefined || !outro.interactive || !this.#customViewport || this.#damageTerminal === null)
|
|
1418
|
+
return null;
|
|
1419
|
+
if (!this.runtime.active || this.runtime.mode !== "fullscreen")
|
|
1420
|
+
return null;
|
|
1421
|
+
try {
|
|
1422
|
+
const { enabled, effect, durationMs } = outro.snapshot();
|
|
1423
|
+
if (!enabled)
|
|
1424
|
+
return null;
|
|
1425
|
+
const viewport = this.runtime.viewport();
|
|
1426
|
+
return { rows: this.#damageTerminal.presentedRows(), columns: viewport.columns, height: viewport.rows, settings: { effect, durationMs } };
|
|
1427
|
+
}
|
|
1428
|
+
catch {
|
|
1429
|
+
return null;
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
#freezeQuitPresentation() {
|
|
1433
|
+
if (!this.#customViewport || !this.runtime.active || this.runtime.mode !== "fullscreen")
|
|
1434
|
+
return;
|
|
1435
|
+
this.runtime.freezePresentation();
|
|
1436
|
+
}
|
|
1437
|
+
// Rationale: any failure here only skips the effect; restoration always follows.
|
|
1438
|
+
async #playQuitOutro(capture) {
|
|
1439
|
+
const outro = this.#quitOutro;
|
|
1440
|
+
if (capture === null || outro === undefined || !this.runtime.active)
|
|
1441
|
+
return;
|
|
1442
|
+
try {
|
|
1443
|
+
// Rationale: the effects stay off the startup graph; quit is the only time they load.
|
|
1444
|
+
const { captureQuitOutroFrame, playQuitOutro } = await import("./quit-outro.js");
|
|
1445
|
+
const frame = captureQuitOutroFrame(capture.rows, capture.columns, capture.height);
|
|
1446
|
+
if (frame === null || !this.runtime.active)
|
|
1447
|
+
return;
|
|
1448
|
+
await playQuitOutro(frame, capture.settings.effect, capture.settings.durationMs, {
|
|
1449
|
+
write: data => this.runtime.writeControl(data),
|
|
1450
|
+
...(outro.now === undefined ? {} : { now: outro.now }),
|
|
1451
|
+
...(outro.sleep === undefined ? {} : { sleep: outro.sleep }),
|
|
1452
|
+
...(outro.seed === undefined ? {} : { seed: outro.seed }),
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
catch {
|
|
1456
|
+
// Rationale: a failed or interrupted effect must never hold the terminal; restoration follows.
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1399
1459
|
#settleStoppedLifecycle() {
|
|
1400
1460
|
void this.dispose().then(() => this.#resolveStoppedLifecycle(), () => this.#resolveStoppedLifecycle());
|
|
1401
1461
|
}
|
|
@@ -26,10 +26,18 @@ export declare class PiTuiRuntimeAdapter {
|
|
|
26
26
|
hasFocusedOverlay(): boolean;
|
|
27
27
|
/**
|
|
28
28
|
* Writes a terminal control sequence. Used to enable and disable mouse
|
|
29
|
-
* reporting while an A1-owned application is presented
|
|
30
|
-
*
|
|
29
|
+
* reporting while an A1-owned application is presented and to paint the quit
|
|
30
|
+
* outro while presentation is frozen, and for nothing else: the transparent
|
|
31
|
+
* and pinned paths never call it.
|
|
31
32
|
*/
|
|
32
33
|
writeControl(data: string): void;
|
|
34
|
+
/**
|
|
35
|
+
* Drops every pinned frame write until the runtime stops. The quit outro owns
|
|
36
|
+
* the alternate screen between the last presented frame and the leave; the
|
|
37
|
+
* renderer keeps scheduling but nothing it paints reaches the terminal.
|
|
38
|
+
*/
|
|
39
|
+
freezePresentation(): void;
|
|
40
|
+
get presentationFrozen(): boolean;
|
|
33
41
|
addPreInputListener(listener: PiTuiPreInputListener): () => void;
|
|
34
42
|
addInputListener(listener: PiTuiInputListener): () => void;
|
|
35
43
|
switchMode(mode: "regular" | "fullscreen"): boolean;
|
|
@@ -120,15 +120,20 @@ export class PiTuiRuntimeAdapter {
|
|
|
120
120
|
#stopPromise;
|
|
121
121
|
#rootDisposed = false;
|
|
122
122
|
#terminalProgress = false;
|
|
123
|
+
#presentationFrozen = false;
|
|
123
124
|
constructor(options) {
|
|
124
125
|
this.#root = options.root;
|
|
125
126
|
this.#overlayGeometry = options.onOverlayGeometry === undefined ? undefined : new OverlayGeometryTracker(options.onOverlayGeometry);
|
|
126
127
|
this.#terminal = options.terminal ?? new ProcessTerminal();
|
|
127
128
|
this.#inputDiagnostics = options.inputDiagnostics;
|
|
128
129
|
this.#diagnosticNow = options.inputDiagnostics?.now ?? (() => performance.now());
|
|
130
|
+
// Invariant: the pinned renderer's frames pass through this gate, while writeControl and
|
|
131
|
+
// the stop sequence reach the terminal directly. Freezing drops frames without touching
|
|
132
|
+
// the renderer, so a scheduled repaint cannot land on top of the quit outro.
|
|
133
|
+
const gatedTerminal = frozenGateTerminal(this.#terminal, () => this.#presentationFrozen);
|
|
129
134
|
const tracedTerminal = options.inputDiagnostics === undefined
|
|
130
|
-
?
|
|
131
|
-
: diagnosticTerminal(
|
|
135
|
+
? gatedTerminal
|
|
136
|
+
: diagnosticTerminal(gatedTerminal, phase => this.#traceRuntimePhase(phase));
|
|
132
137
|
const decoratedTerminal = options.decorateTerminal?.(tracedTerminal) ?? tracedTerminal;
|
|
133
138
|
const coordination = options.inputCoordination ?? (options.inputDiagnostics === undefined
|
|
134
139
|
? undefined
|
|
@@ -297,13 +302,26 @@ export class PiTuiRuntimeAdapter {
|
|
|
297
302
|
}
|
|
298
303
|
/**
|
|
299
304
|
* Writes a terminal control sequence. Used to enable and disable mouse
|
|
300
|
-
* reporting while an A1-owned application is presented
|
|
301
|
-
*
|
|
305
|
+
* reporting while an A1-owned application is presented and to paint the quit
|
|
306
|
+
* outro while presentation is frozen, and for nothing else: the transparent
|
|
307
|
+
* and pinned paths never call it.
|
|
302
308
|
*/
|
|
303
309
|
writeControl(data) {
|
|
304
310
|
this.#assertRunning("control sequence");
|
|
305
311
|
this.#terminal.write(data);
|
|
306
312
|
}
|
|
313
|
+
/**
|
|
314
|
+
* Drops every pinned frame write until the runtime stops. The quit outro owns
|
|
315
|
+
* the alternate screen between the last presented frame and the leave; the
|
|
316
|
+
* renderer keeps scheduling but nothing it paints reaches the terminal.
|
|
317
|
+
*/
|
|
318
|
+
freezePresentation() {
|
|
319
|
+
this.#assertRunning("presentation freeze");
|
|
320
|
+
this.#presentationFrozen = true;
|
|
321
|
+
}
|
|
322
|
+
get presentationFrozen() {
|
|
323
|
+
return this.#presentationFrozen;
|
|
324
|
+
}
|
|
307
325
|
addPreInputListener(listener) {
|
|
308
326
|
if (this.#preInputListeners.has(listener))
|
|
309
327
|
throw new TypeError("Pi TUI pre-input listener is already registered");
|
|
@@ -433,6 +451,9 @@ export class PiTuiRuntimeAdapter {
|
|
|
433
451
|
}
|
|
434
452
|
try {
|
|
435
453
|
const stopOptions = options.preserveScreen === undefined ? undefined : { preserveScreen: options.preserveScreen };
|
|
454
|
+
// Invariant: the gate opens only for the synchronous stop sequence, so no render
|
|
455
|
+
// scheduled during the outro can slip in before the alternate screen is left.
|
|
456
|
+
this.#presentationFrozen = false;
|
|
436
457
|
this.#tui.stop(stopOptions);
|
|
437
458
|
}
|
|
438
459
|
catch (error) {
|
|
@@ -566,6 +587,7 @@ export class PiTuiRuntimeAdapter {
|
|
|
566
587
|
throw new Error(`Pi TUI ${operation} requires a running runtime`);
|
|
567
588
|
}
|
|
568
589
|
#restoreAfterFailedStart() {
|
|
590
|
+
this.#presentationFrozen = false;
|
|
569
591
|
try {
|
|
570
592
|
this.#tui.stop();
|
|
571
593
|
}
|
|
@@ -581,6 +603,7 @@ export class PiTuiRuntimeAdapter {
|
|
|
581
603
|
this.#terminalProgress = false;
|
|
582
604
|
}
|
|
583
605
|
#bestEffortTerminalRestore() {
|
|
606
|
+
this.#presentationFrozen = false;
|
|
584
607
|
this.#clearTerminalProgress();
|
|
585
608
|
try {
|
|
586
609
|
if (this.mode === "fullscreen")
|
|
@@ -676,6 +699,32 @@ function diagnosticTerminal(terminal, trace) {
|
|
|
676
699
|
setProgress: active => terminal.setProgress(active),
|
|
677
700
|
};
|
|
678
701
|
}
|
|
702
|
+
function frozenGateTerminal(terminal, frozen) {
|
|
703
|
+
return {
|
|
704
|
+
get columns() { return terminal.columns; },
|
|
705
|
+
get rows() { return terminal.rows; },
|
|
706
|
+
get kittyProtocolActive() { return terminal.kittyProtocolActive; },
|
|
707
|
+
start: (onInput, onResize) => terminal.start(onInput, onResize),
|
|
708
|
+
stop: () => terminal.stop(),
|
|
709
|
+
drainInput: (maxMs, idleMs) => terminal.drainInput(maxMs, idleMs),
|
|
710
|
+
write: data => { if (!frozen())
|
|
711
|
+
terminal.write(data); },
|
|
712
|
+
moveBy: lines => { if (!frozen())
|
|
713
|
+
terminal.moveBy(lines); },
|
|
714
|
+
hideCursor: () => { if (!frozen())
|
|
715
|
+
terminal.hideCursor(); },
|
|
716
|
+
showCursor: () => { if (!frozen())
|
|
717
|
+
terminal.showCursor(); },
|
|
718
|
+
clearLine: () => { if (!frozen())
|
|
719
|
+
terminal.clearLine(); },
|
|
720
|
+
clearFromCursor: () => { if (!frozen())
|
|
721
|
+
terminal.clearFromCursor(); },
|
|
722
|
+
clearScreen: () => { if (!frozen())
|
|
723
|
+
terminal.clearScreen(); },
|
|
724
|
+
setTitle: title => terminal.setTitle(title),
|
|
725
|
+
setProgress: active => terminal.setProgress(active),
|
|
726
|
+
};
|
|
727
|
+
}
|
|
679
728
|
function preInputTerminal(terminal, route, frameMouse) {
|
|
680
729
|
let mouse;
|
|
681
730
|
return {
|