@timurproko/a1 0.1.8-dev.447 → 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.
- package/README.md +9 -0
- package/dist/composition/owned-ui.js +11 -0
- package/dist/contracts/owned-ui/model.d.ts +10 -0
- 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 +57 -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/settings/declarations.d.ts +3 -1
- package/dist/ui/settings/declarations.js +22 -1
- package/dist/ui/settings/migrations.js +7 -0
- package/docs/ci-release-runbook.md +5 -5
- package/docs/local-worktree-cleanup.md +8 -4
- package/docs/manual-owned-ui-checkpoint.md +3 -2
- package/docs/openspec-archive-automation.md +21 -8
- package/docs/validation.md +4 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -71,6 +71,15 @@ preferred when consistent with your intent; genuinely unclear cases can remain
|
|
|
71
71
|
empty. [Private diagnostic capture](docs/architecture/prompt-suggestions.md)
|
|
72
72
|
explains how to distinguish skipped, empty, rejected, and timed-out suggestions.
|
|
73
73
|
|
|
74
|
+
## Quitting
|
|
75
|
+
|
|
76
|
+
`/quit`, `Ctrl+C` twice, and `Ctrl+D` on an empty prompt end a bare A1 session.
|
|
77
|
+
The last screen dissolves on the alternate screen, the terminal is restored once,
|
|
78
|
+
and only the dim `To resume this session:` hint is printed; the conversation is
|
|
79
|
+
not echoed into your scrollback. `/settings` → Quit chooses the effect (`fall`,
|
|
80
|
+
`dissolve`, `starburst`, `waves`, or `off`) and its duration (300–2000 ms). The
|
|
81
|
+
`a1 pi` comparison profile keeps Pi's `fullscreenExitOutput` behavior.
|
|
82
|
+
|
|
74
83
|
## Prompt history
|
|
75
84
|
|
|
76
85
|
Bare A1 recalls recent unique prompts across sessions with Up/Down. History is
|
|
@@ -84,6 +84,9 @@ export async function composeOwnedUi(options = {}) {
|
|
|
84
84
|
inputPresentation: { onEvent: event => clipboardDiagnostics.runtime(event) },
|
|
85
85
|
}),
|
|
86
86
|
...(viewportSettings === null ? {} : { viewportSettings }),
|
|
87
|
+
...(settings === null || !ownedSurfaces ? {} : {
|
|
88
|
+
quitOutro: { snapshot: () => quitOutroSettingsSnapshot(settings), interactive: process.stdout.isTTY === true },
|
|
89
|
+
}),
|
|
87
90
|
...(promptSuggestions === null ? {} : { promptSuggestions }),
|
|
88
91
|
...(promptHistory === null ? {} : { promptHistory: {
|
|
89
92
|
...promptHistory,
|
|
@@ -113,6 +116,14 @@ export async function composeOwnedUi(options = {}) {
|
|
|
113
116
|
};
|
|
114
117
|
return { application, settings };
|
|
115
118
|
}
|
|
119
|
+
function quitOutroSettingsSnapshot(settings) {
|
|
120
|
+
const effect = settings.value("quitEffect");
|
|
121
|
+
const durationMs = settings.value("quitEffectDurationMs");
|
|
122
|
+
return {
|
|
123
|
+
effect: effect === "dissolve" || effect === "starburst" || effect === "waves" || effect === "off" ? effect : "fall",
|
|
124
|
+
durationMs: typeof durationMs === "number" ? durationMs : 800,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
116
127
|
function viewportSettingsSnapshot(settings) {
|
|
117
128
|
const appearance = settings.value("scrollbarAppearance");
|
|
118
129
|
const style = settings.value("scrollbarStyle");
|
|
@@ -64,6 +64,16 @@ export interface OwnedUiViewportSettingsPort {
|
|
|
64
64
|
snapshot(): OwnedUiViewportSettings;
|
|
65
65
|
onChange(listener: (settings: OwnedUiViewportSettings) => void): () => void;
|
|
66
66
|
}
|
|
67
|
+
export type OwnedUiQuitEffect = "fall" | "dissolve" | "starburst" | "waves" | "off";
|
|
68
|
+
/** Profile-local quit outro choice, read at the moment bare A1 quits. */
|
|
69
|
+
export interface OwnedUiQuitOutroSettings {
|
|
70
|
+
readonly effect: OwnedUiQuitEffect;
|
|
71
|
+
/** Requested playback length; the player clamps it to its supported range. */
|
|
72
|
+
readonly durationMs: number;
|
|
73
|
+
}
|
|
74
|
+
export interface OwnedUiQuitOutroSettingsPort {
|
|
75
|
+
snapshot(): OwnedUiQuitOutroSettings;
|
|
76
|
+
}
|
|
67
77
|
export interface OwnedUiTerminalSurface {
|
|
68
78
|
readonly columns: number;
|
|
69
79
|
readonly rows: number;
|
|
@@ -63,6 +63,7 @@ export function createPiShellEditor(options) {
|
|
|
63
63
|
...(options.keybindingProfile === "a1" ? {
|
|
64
64
|
terminalRows: options.getRows,
|
|
65
65
|
getVisualLineCount: (width) => editorVisualLineCount(editor, width),
|
|
66
|
+
clearCommandSearchOnEscape: true,
|
|
66
67
|
} : {}),
|
|
67
68
|
...(options.keybindingProfile === "a1" && options.promptPresentation !== undefined ? {
|
|
68
69
|
...(inputPresentation === undefined ? {} : { inputPresentation }),
|
|
@@ -17,6 +17,8 @@ export interface OwnedEditorOptions extends EditorOptions {
|
|
|
17
17
|
readonly terminalRows?: () => number;
|
|
18
18
|
/** Reuses the editor's established atomic-aware visual layout when available. */
|
|
19
19
|
readonly getVisualLineCount?: (width: number) => number | undefined;
|
|
20
|
+
/** Bare-A1 exception: Escape on a sole top-level slash-command search also clears the prompt. */
|
|
21
|
+
readonly clearCommandSearchOnEscape?: boolean;
|
|
20
22
|
}
|
|
21
23
|
export interface ShellEditorInstance extends EditorSurface {
|
|
22
24
|
readonly actionHandlers: Map<AppKeybinding, () => void>;
|
|
@@ -21,6 +21,7 @@ export function createOwnedEditorClass(Base) {
|
|
|
21
21
|
#styleSuggestionCaret;
|
|
22
22
|
#terminalRows;
|
|
23
23
|
#getVisualLineCount;
|
|
24
|
+
#clearCommandSearchOnEscape;
|
|
24
25
|
#renderedBodyRowCount = 0;
|
|
25
26
|
constructor(tui, theme, keybindings, options = {}) {
|
|
26
27
|
super(tui, theme, options);
|
|
@@ -30,6 +31,7 @@ export function createOwnedEditorClass(Base) {
|
|
|
30
31
|
this.#styleSuggestionCaret = options.styleSuggestionCaret ?? (text => `\u001b[7m${text}\u001b[27m`);
|
|
31
32
|
this.#terminalRows = options.terminalRows ?? (() => 24);
|
|
32
33
|
this.#getVisualLineCount = options.getVisualLineCount;
|
|
34
|
+
this.#clearCommandSearchOnEscape = options.clearCommandSearchOnEscape === true;
|
|
33
35
|
}
|
|
34
36
|
getRenderedBodyRowCount() { return this.#renderedBodyRowCount; }
|
|
35
37
|
setPromptSuggestion(text) {
|
|
@@ -42,6 +44,16 @@ export function createOwnedEditorClass(Base) {
|
|
|
42
44
|
&& this.getText().length === 0
|
|
43
45
|
&& !this.isShowingAutocomplete();
|
|
44
46
|
}
|
|
47
|
+
/** True when autocomplete is searching one top-level slash command that is the editor's only content. */
|
|
48
|
+
isTopLevelCommandSearch() {
|
|
49
|
+
if (!this.isShowingAutocomplete())
|
|
50
|
+
return false;
|
|
51
|
+
const text = this.getText();
|
|
52
|
+
if (!/^\/[^\s/]*$/.test(text))
|
|
53
|
+
return false;
|
|
54
|
+
const cursor = this.getCursor();
|
|
55
|
+
return cursor.line === 0 && cursor.col === text.length;
|
|
56
|
+
}
|
|
45
57
|
setText(text) {
|
|
46
58
|
if (text.length > 0)
|
|
47
59
|
this.#promptSuggestion = null;
|
|
@@ -92,6 +104,11 @@ export function createOwnedEditorClass(Base) {
|
|
|
92
104
|
return;
|
|
93
105
|
}
|
|
94
106
|
}
|
|
107
|
+
else if (this.#clearCommandSearchOnEscape && this.isTopLevelCommandSearch()) {
|
|
108
|
+
// Escape on a bare slash-command search restores the empty prompt instead of only closing the menu.
|
|
109
|
+
this.setText("");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
95
112
|
super.handleInput(data);
|
|
96
113
|
return;
|
|
97
114
|
}
|
|
@@ -178,6 +178,8 @@ export declare class PiEngineAdapter implements OwnedUiPromptSuggestionGenerator
|
|
|
178
178
|
configuredTheme(): string | undefined;
|
|
179
179
|
/** Settings port for the live runtime, or null before the runtime is available. */
|
|
180
180
|
settingsPort(): PiSettingsIntegration | null;
|
|
181
|
+
/** Which settings surface this adapter serves; hidden-in-bare effects are unbindable in bare mode. */
|
|
182
|
+
get settingsProductMode(): "bare" | "comparison";
|
|
181
183
|
bindSettingsOwner(owner: AgentSettingOwner, handlers: PiSettingOwnerHandlers): () => void;
|
|
182
184
|
pinnedModelSelectorContext(): {
|
|
183
185
|
readonly currentModel: unknown;
|
|
@@ -224,10 +224,8 @@ export class PiEngineAdapter {
|
|
|
224
224
|
return "unavailable";
|
|
225
225
|
if (!session.model.reasoning)
|
|
226
226
|
return "ordinary";
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
return ["off", "minimal", "low", "medium", "high", "xhigh", "max"]
|
|
230
|
-
.find(level => levels.includes(level)) ?? "unavailable";
|
|
227
|
+
// Performance: the run's own level keeps the provider's thinking parameters, and the cached prefix, identical.
|
|
228
|
+
return readSuggestionReasoning(session.thinkingLevel);
|
|
231
229
|
}
|
|
232
230
|
async generate(request) {
|
|
233
231
|
assertOwnedUiPromptSuggestionRequest(request);
|
|
@@ -246,15 +244,23 @@ export class PiEngineAdapter {
|
|
|
246
244
|
return { identity, outcome: request.signal.aborted ? "cancelled" : "unavailable", text: null };
|
|
247
245
|
}
|
|
248
246
|
const model = session.model;
|
|
249
|
-
const
|
|
247
|
+
const agent = session.agent;
|
|
248
|
+
const agentState = agent.state;
|
|
250
249
|
const policy = this.suggestionReasoningPolicy();
|
|
251
250
|
if (model === undefined || policy === "unavailable" || typeof runtime.services.modelRuntime.completeSimple !== "function") {
|
|
252
251
|
return { identity, outcome: "unavailable", text: null };
|
|
253
252
|
}
|
|
254
|
-
|
|
253
|
+
// Performance: mirror the primary loop's request shape so the provider serves the conversation prefix
|
|
254
|
+
// from the run's prompt cache. `onResponse` stays out: extensions must not see a suggestion as a response.
|
|
255
255
|
const reasoning = policy === "ordinary" || policy === "off" ? undefined : policy;
|
|
256
256
|
let response;
|
|
257
257
|
try {
|
|
258
|
+
const transformed = typeof agent.transformContext === "function"
|
|
259
|
+
? await agent.transformContext(agentState.messages, request.signal)
|
|
260
|
+
: agentState.messages;
|
|
261
|
+
const messages = typeof agent.convertToLlm === "function"
|
|
262
|
+
? await agent.convertToLlm(transformed)
|
|
263
|
+
: transformed.filter(message => message.role === "user" || message.role === "assistant" || message.role === "toolResult");
|
|
258
264
|
response = await runtime.services.modelRuntime.completeSimple(model, {
|
|
259
265
|
systemPrompt: agentState.systemPrompt,
|
|
260
266
|
messages: [
|
|
@@ -265,6 +271,10 @@ export class PiEngineAdapter {
|
|
|
265
271
|
}, {
|
|
266
272
|
signal: request.signal,
|
|
267
273
|
...(reasoning === undefined ? {} : { reasoning }),
|
|
274
|
+
...(agent.sessionId === undefined ? {} : { sessionId: agent.sessionId }),
|
|
275
|
+
...(agent.thinkingBudgets === undefined ? {} : { thinkingBudgets: agent.thinkingBudgets }),
|
|
276
|
+
...(agent.transport === undefined ? {} : { transport: agent.transport }),
|
|
277
|
+
...(agent.onPayload === undefined ? {} : { onPayload: agent.onPayload }),
|
|
268
278
|
});
|
|
269
279
|
}
|
|
270
280
|
catch {
|
|
@@ -748,6 +758,10 @@ export class PiEngineAdapter {
|
|
|
748
758
|
}
|
|
749
759
|
return this.#settingsIntegration;
|
|
750
760
|
}
|
|
761
|
+
/** Which settings surface this adapter serves; hidden-in-bare effects are unbindable in bare mode. */
|
|
762
|
+
get settingsProductMode() {
|
|
763
|
+
return this.#settingsProductMode;
|
|
764
|
+
}
|
|
751
765
|
bindSettingsOwner(owner, handlers) {
|
|
752
766
|
const settings = this.settingsPort();
|
|
753
767
|
if (settings === null)
|
|
@@ -3235,6 +3249,11 @@ function readModel(value) {
|
|
|
3235
3249
|
displayName: stringValue(value.name) ?? modelId,
|
|
3236
3250
|
};
|
|
3237
3251
|
}
|
|
3252
|
+
function readSuggestionReasoning(value) {
|
|
3253
|
+
return value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "max"
|
|
3254
|
+
? value
|
|
3255
|
+
: "off";
|
|
3256
|
+
}
|
|
3238
3257
|
function readThinkingLevel(value) {
|
|
3239
3258
|
return value === "off" || value === "minimal" || value === "low" || value === "medium"
|
|
3240
3259
|
|| value === "high" || value === "xhigh"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AgentJsonValue, AgentSettingApplicationBoundary, AgentSettingChangeOutcome, AgentSettingOwner } from "../../../contracts/agent-engine/index.js";
|
|
2
2
|
export type PiSettingKey = "autoCompact" | "showImages" | "imageWidthCells" | "autoResizeImages" | "blockImages" | "enableSkillCommands" | "steeringMode" | "followUpMode" | "transport" | "httpIdleTimeoutMs" | "thinkingLevel" | "theme" | "hideThinkingBlock" | "mermaidRenderingMode" | "showCacheMissNotices" | "collapseChangelog" | "enableInstallTelemetry" | "quietStartup" | "defaultProjectTrust" | "doubleEscapeAction" | "treeFilterMode" | "showHardwareCursor" | "editorPaddingX" | "outputPad" | "autocompleteMaxVisible" | "clearOnShrink" | "showTerminalProgress" | "tuiMode" | "fullscreenExitOutput" | "fullscreenScrollbar" | "warnings";
|
|
3
|
-
export type PiSettingVisualClass = "none" | "transcript" | "transcript-geometry" | "editor-menu" | "queue-transcript" | "status-error" | "retry-error" | "footer-transcript" | "markdown" | "transcript-notice" | "startup-transcript" | "startup-selector" | "selector" | "terminal-cursor" | "editor-geometry" | "menu-geometry" | "terminal-frame" | "terminal-status" | "
|
|
3
|
+
export type PiSettingVisualClass = "none" | "transcript" | "transcript-geometry" | "editor-menu" | "queue-transcript" | "status-error" | "retry-error" | "footer-transcript" | "markdown" | "transcript-notice" | "startup-transcript" | "startup-selector" | "selector" | "terminal-cursor" | "editor-geometry" | "menu-geometry" | "terminal-frame" | "terminal-status" | "hidden";
|
|
4
4
|
export interface PiSettingVisualEvidence {
|
|
5
5
|
/** Reviewed visual family; `none` still names the behavior that can emit styled diagnostics. */
|
|
6
6
|
readonly class: PiSettingVisualClass;
|
|
@@ -31,7 +31,7 @@ export const PI_SETTING_EFFECTS = Object.freeze({
|
|
|
31
31
|
clearOnShrink: effect("live", "terminal", "terminal-frame", "pinned resize clearing and resulting terminal frame", "pi-terminal-operation-parity"),
|
|
32
32
|
showTerminalProgress: effect("live", "terminal", "terminal-status", "pinned OSC progress lifecycle", "pi-terminal-operation-parity"),
|
|
33
33
|
tuiMode: hiddenEffect("next-session", "shell", "pinned regular/fullscreen selector and terminal lifecycle", "pi-terminal-operation-parity"),
|
|
34
|
-
fullscreenExitOutput:
|
|
34
|
+
fullscreenExitOutput: hiddenEffect("current-exit", "shutdown", "pinned styled transcript and compact dim resume hint", "pinned-fullscreen-exit-parity"),
|
|
35
35
|
fullscreenScrollbar: hiddenEffect("live", "shell", "pinned fullscreen scrollbar reservation", "pi-terminal-operation-parity"),
|
|
36
36
|
warnings: effect("live", "agent", "transcript-notice", "pinned warning rows by warning part", "pinned-transcript-lifecycle-parity"),
|
|
37
37
|
});
|
|
@@ -0,0 +1,29 @@
|
|
|
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 interface QuitOutroCell {
|
|
10
|
+
readonly row: number;
|
|
11
|
+
readonly col: number;
|
|
12
|
+
/** Progress at which the sparkle glyph is painted. */
|
|
13
|
+
readonly start: number;
|
|
14
|
+
/** Progress at which the cell is cleared. */
|
|
15
|
+
readonly end: number;
|
|
16
|
+
readonly glyph: string;
|
|
17
|
+
readonly color: string;
|
|
18
|
+
}
|
|
19
|
+
export interface QuitOutroPlan {
|
|
20
|
+
/** Sorted by `start`. */
|
|
21
|
+
readonly sparkles: readonly QuitOutroCell[];
|
|
22
|
+
/** Sorted by `end`. */
|
|
23
|
+
readonly clears: readonly QuitOutroCell[];
|
|
24
|
+
}
|
|
25
|
+
export declare const QUIT_OUTRO_EFFECTS: readonly ["fall", "dissolve", "starburst", "waves"];
|
|
26
|
+
export type QuitOutroEffect = (typeof QUIT_OUTRO_EFFECTS)[number];
|
|
27
|
+
export declare function isQuitOutroEffect(value: unknown): value is QuitOutroEffect;
|
|
28
|
+
/** Builds the selected effect's deterministic plan for the captured row widths. */
|
|
29
|
+
export declare function createQuitOutroPlan(effect: QuitOutroEffect, rowWidths: readonly number[], seed: number): QuitOutroPlan;
|
|
@@ -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
|
+
}
|