@timurproko/a1 0.1.8-dev.444 → 0.1.8-dev.457

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 (37) hide show
  1. package/README.md +9 -0
  2. package/dist/composition/owned-ui.js +11 -0
  3. package/dist/contracts/owned-ui/model.d.ts +10 -0
  4. package/dist/foundation/release/update.js +1 -1
  5. package/dist/foundation/startup/startup-descriptor.js +2 -2
  6. package/dist/integrations/pi/components/shell-editor-autocomplete.js +1 -0
  7. package/dist/integrations/pi/components/upstream/components/owned-editor.d.ts +2 -0
  8. package/dist/integrations/pi/components/upstream/components/owned-editor.js +17 -0
  9. package/dist/integrations/pi/engine/adapter.d.ts +2 -0
  10. package/dist/integrations/pi/engine/adapter.js +25 -6
  11. package/dist/integrations/pi/engine/settings-effects.d.ts +1 -1
  12. package/dist/integrations/pi/engine/settings-effects.js +1 -1
  13. package/dist/integrations/pi/session-ui/quit-outro-effects.d.ts +29 -0
  14. package/dist/integrations/pi/session-ui/quit-outro-effects.js +242 -0
  15. package/dist/integrations/pi/session-ui/quit-outro.d.ts +38 -0
  16. package/dist/integrations/pi/session-ui/quit-outro.js +98 -0
  17. package/dist/integrations/pi/session-ui/session-shell-root.d.ts +12 -1
  18. package/dist/integrations/pi/session-ui/session-shell.js +57 -5
  19. package/dist/integrations/pi/startup-public.js +86 -13
  20. package/dist/integrations/pi/startup-public.manifest.json +121 -61
  21. package/dist/integrations/pi/tui-runtime/adapter.d.ts +10 -2
  22. package/dist/integrations/pi/tui-runtime/adapter.js +53 -4
  23. package/dist/integrations/pi/tui-runtime/damage-aware-terminal.d.ts +7 -0
  24. package/dist/integrations/pi/tui-runtime/damage-aware-terminal.js +12 -0
  25. package/dist/native/darwin-arm64/manifest.json +1 -1
  26. package/dist/native/linux-x64/manifest.json +1 -1
  27. package/dist/native/win32-x64/manifest.json +2 -2
  28. package/dist/native/win32-x64/process-guardian.exe +0 -0
  29. package/dist/ui/settings/declarations.d.ts +3 -1
  30. package/dist/ui/settings/declarations.js +22 -1
  31. package/dist/ui/settings/migrations.js +7 -0
  32. package/docs/ci-release-runbook.md +10 -10
  33. package/docs/local-worktree-cleanup.md +8 -4
  34. package/docs/manual-owned-ui-checkpoint.md +3 -2
  35. package/docs/openspec-archive-automation.md +21 -8
  36. package/docs/validation.md +5 -3
  37. package/package.json +1 -1
@@ -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
- this.#unbindShutdownSettings = this.backend.bindSettingsOwner("shutdown", {
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 : [exitTranscript, resumeHint].filter(Boolean).join("\n\n");
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,11 @@ export class OwnedUiSessionShell {
1386
1394
  attempt(() => this.#unsubscribe());
1387
1395
  attempt(() => this.#dialogHandle?.hide());
1388
1396
  attempt(() => this.#extensionBridge.dispose());
1389
- // Invariant: terminal restoration precedes any potentially stalled backend teardown.
1390
- await this.runtime.dispose().catch(error => failures.push(error));
1397
+ await this.#playQuitOutro(outroFrame);
1398
+ // Invariant: terminal restoration precedes any potentially stalled backend teardown. The
1399
+ // fullscreen leave preserves the screen: the pinned runtime never dumps its final document
1400
+ // into the parent terminal, so only the configured exit text follows the leave.
1401
+ await this.runtime.dispose({ preserveScreen: this.runtime.mode === "fullscreen" }).catch(error => failures.push(error));
1391
1402
  await historyCleanup.catch(() => false); // Security: background durability outcomes never enter terminal output.
1392
1403
  await boundedCleanup(() => pasteCleanup).catch(error => failures.push(error));
1393
1404
  await boundedCleanup(() => this.backend.unbindExtensionUi()).catch(error => failures.push(error));
@@ -1396,6 +1407,47 @@ export class OwnedUiSessionShell {
1396
1407
  if (fullscreenExitText.length > 0)
1397
1408
  this.runtime.writeAfterStop(`${fullscreenExitText}\n`);
1398
1409
  }
1410
+ // Invariant: the snapshot is synchronous; the outro module itself loads only at quit.
1411
+ #captureQuitOutroFrame() {
1412
+ const outro = this.#quitOutro;
1413
+ if (outro === undefined || !outro.interactive || !this.#customViewport || this.#damageTerminal === null)
1414
+ return null;
1415
+ if (!this.runtime.active || this.runtime.mode !== "fullscreen")
1416
+ return null;
1417
+ try {
1418
+ const { effect, durationMs } = outro.snapshot();
1419
+ if (effect === "off")
1420
+ return null;
1421
+ const viewport = this.runtime.viewport();
1422
+ return { rows: this.#damageTerminal.presentedRows(), columns: viewport.columns, height: viewport.rows, settings: { effect, durationMs } };
1423
+ }
1424
+ catch {
1425
+ return null;
1426
+ }
1427
+ }
1428
+ // Rationale: any failure here only skips the effect; restoration always follows.
1429
+ async #playQuitOutro(capture) {
1430
+ const outro = this.#quitOutro;
1431
+ if (capture === null || outro === undefined || !this.runtime.active)
1432
+ return;
1433
+ try {
1434
+ // Rationale: the effects stay off the startup graph; quit is the only time they load.
1435
+ const { captureQuitOutroFrame, playQuitOutro } = await import("./quit-outro.js");
1436
+ const frame = captureQuitOutroFrame(capture.rows, capture.columns, capture.height);
1437
+ if (frame === null || !this.runtime.active)
1438
+ return;
1439
+ this.runtime.freezePresentation();
1440
+ await playQuitOutro(frame, capture.settings.effect, capture.settings.durationMs, {
1441
+ write: data => this.runtime.writeControl(data),
1442
+ ...(outro.now === undefined ? {} : { now: outro.now }),
1443
+ ...(outro.sleep === undefined ? {} : { sleep: outro.sleep }),
1444
+ ...(outro.seed === undefined ? {} : { seed: outro.seed }),
1445
+ });
1446
+ }
1447
+ catch {
1448
+ // Rationale: a failed or interrupted effect must never hold the terminal; restoration follows.
1449
+ }
1450
+ }
1399
1451
  #settleStoppedLifecycle() {
1400
1452
  void this.dispose().then(() => this.#resolveStoppedLifecycle(), () => this.#resolveStoppedLifecycle());
1401
1453
  }