@timurproko/a1 0.2.6-dev.734 → 0.2.6-dev.736
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/dist/app/session-shell/index.d.ts +2 -2
- package/dist/app/session-shell/index.js +1 -1
- package/dist/app/session-shell/session-shell-root.d.ts +9 -1
- package/dist/app/session-shell/session-shell-root.js +56 -47
- package/dist/app/session-shell/session-shell.d.ts +37 -9
- package/dist/app/session-shell/session-shell.js +266 -446
- package/dist/app/session-shell/shell-host.d.ts +105 -0
- package/dist/app/session-shell/shell-host.js +523 -0
- package/dist/cli/session-context.js +6 -3
- package/dist/composition/owned-ui.js +45 -25
- package/dist/contracts/owned-ui/index.d.ts +1 -1
- package/dist/contracts/owned-ui/session-presenters.d.ts +35 -8
- package/dist/features/launch/index.d.ts +1 -1
- package/dist/features/launch/index.js +1 -1
- package/dist/features/launch/session-context.d.ts +1 -0
- package/dist/features/launch/session-context.js +1 -0
- package/dist/foundation/lifecycle/index.d.ts +1 -1
- package/dist/foundation/lifecycle/index.js +1 -1
- package/dist/foundation/lifecycle/session-repository-context.d.ts +8 -2
- package/dist/foundation/lifecycle/session-repository-context.js +200 -65
- package/dist/integrations/pi/tui-runtime/damage-aware-terminal.d.ts +11 -0
- package/dist/integrations/pi/tui-runtime/damage-aware-terminal.js +18 -2
- 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/docs/architecture/ui-reference-provenance.md +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { OwnedUiBackgroundSettingsPort, OwnedUiPresenterHost, OwnedUiSessionPresenter, OwnedUiTerminalHost as OwnedUiTerminalHostContract } from "../../contracts/owned-ui/index.js";
|
|
2
|
+
import type { UiRouteSurface } from "../../ui/apps/contracts.js";
|
|
3
|
+
import { type PiTuiDamageDecision, type PiTuiDamageFrameDescriptor, type PiTuiDamageFrameSafety } from "../../integrations/pi/tui-runtime/damage-aware-terminal.js";
|
|
4
|
+
import { PiTuiRuntimeAdapter } from "../../integrations/pi/tui-runtime/adapter.js";
|
|
5
|
+
import type { PiTuiInputSurfaceKind } from "../../integrations/pi/tui-runtime/input-presentation-coordinator.js";
|
|
6
|
+
import type { PiTuiComponentPort, PiTuiInputListener, PiTuiInputListenerResult, PiTuiOverlayHandle, PiTuiOverlayOptions, PiTuiPointerSurface, PiTuiTerminalPort } from "../../integrations/pi/tui-runtime/contracts.js";
|
|
7
|
+
import { type OwnedUiSessionShellOptions, type OwnedUiShellPresentationOptions, type PinnedLayoutPart } from "./session-shell-root.js";
|
|
8
|
+
type ProgramStatus = Parameters<PiTuiRuntimeAdapter["setProgramStatus"]>[0];
|
|
9
|
+
type WheelScrollLines = Parameters<PiTuiRuntimeAdapter["setWheelScrollLines"]>[0];
|
|
10
|
+
/** What the terminal host needs from a presenter beyond the neutral contract: the Pi runtime's per-session seams. */
|
|
11
|
+
export interface OwnedUiHostedPresenter extends OwnedUiSessionPresenter {
|
|
12
|
+
inputCoordinationSurface(): PiTuiInputSurfaceKind;
|
|
13
|
+
setOverlaySurfaces(surfaces: readonly PiTuiPointerSurface[] | null): void;
|
|
14
|
+
noteImmediatePresentation(): void;
|
|
15
|
+
/** The bare-A1 viewport's pre-input routing; returns what the runtime's pre-input listener returns. */
|
|
16
|
+
handleViewportInput(data: string): PiTuiInputListenerResult | undefined;
|
|
17
|
+
layoutPart(part: PinnedLayoutPart): PiTuiComponentPort;
|
|
18
|
+
/** Called after the host makes this presenter active, to republish its terminal-wide state. */
|
|
19
|
+
activated(): void;
|
|
20
|
+
/**
|
|
21
|
+
* Releases the presenter's session bindings synchronously. `settle` finishes the asynchronous cleanup the
|
|
22
|
+
* host awaits after the terminal is restored, and resolves to any failures.
|
|
23
|
+
*/
|
|
24
|
+
release(terminal: {
|
|
25
|
+
readonly mode: "regular" | "fullscreen";
|
|
26
|
+
readonly columns: number;
|
|
27
|
+
}): OwnedUiPresenterRelease;
|
|
28
|
+
}
|
|
29
|
+
export interface OwnedUiPresenterRelease {
|
|
30
|
+
/** Text to leave in the parent terminal after a fullscreen leave; empty for none. */
|
|
31
|
+
readonly exitText: string;
|
|
32
|
+
readonly failures: readonly unknown[];
|
|
33
|
+
settle(): Promise<readonly unknown[]>;
|
|
34
|
+
}
|
|
35
|
+
/** The terminal as one presenter reaches it. Paint, input, and status calls from an inactive presenter do nothing. */
|
|
36
|
+
export interface OwnedUiPresenterTerminal extends OwnedUiPresenterHost {
|
|
37
|
+
/** True while the terminal runtime is running, whichever presenter is active. */
|
|
38
|
+
readonly running: boolean;
|
|
39
|
+
/** True while this presenter is the host's active presenter. */
|
|
40
|
+
readonly attached: boolean;
|
|
41
|
+
readonly mode: "regular" | "fullscreen";
|
|
42
|
+
/** Starts the terminal; idempotent. */
|
|
43
|
+
start(): void;
|
|
44
|
+
renderNow(): void;
|
|
45
|
+
writeControl(data: string): void;
|
|
46
|
+
hasOverlay(): boolean;
|
|
47
|
+
hasFocusedOverlay(): boolean;
|
|
48
|
+
showOverlay(component: PiTuiComponentPort, options?: PiTuiOverlayOptions): PiTuiOverlayHandle;
|
|
49
|
+
addInputListener(listener: PiTuiInputListener): () => void;
|
|
50
|
+
armFrame(descriptor: PiTuiDamageFrameDescriptor, safety: PiTuiDamageFrameSafety): void;
|
|
51
|
+
requestHyperlinkCleanup(rows?: readonly number[]): void;
|
|
52
|
+
openRoute(surface: UiRouteSurface, callbacks: OwnedUiRouteCallbacks): {
|
|
53
|
+
hide(): void;
|
|
54
|
+
};
|
|
55
|
+
beginPresentationHold(): () => void;
|
|
56
|
+
switchMode(mode: "regular" | "fullscreen"): boolean;
|
|
57
|
+
setTitle(title: string): void;
|
|
58
|
+
setProgramStatus(status: ProgramStatus): void;
|
|
59
|
+
setTerminalProgress(active: boolean): void;
|
|
60
|
+
setHardwareCursor(enabled: boolean): void;
|
|
61
|
+
setClearOnShrink(enabled: boolean): void;
|
|
62
|
+
setWheelScrollLines(lines: WheelScrollLines): void;
|
|
63
|
+
}
|
|
64
|
+
export interface OwnedUiRouteCallbacks {
|
|
65
|
+
/** The route, or the interrupt chord over it, asked to leave A1. */
|
|
66
|
+
exit(): void;
|
|
67
|
+
/** The route closed itself; `rendered` is true when it was ever painted. */
|
|
68
|
+
closed(rendered: boolean): void;
|
|
69
|
+
}
|
|
70
|
+
export interface OwnedUiTerminalHostOptions {
|
|
71
|
+
/** Bare A1 owns a bounded custom viewport; comparison profiles keep the pinned layout. */
|
|
72
|
+
readonly layout: "pinned" | "custom-viewport";
|
|
73
|
+
readonly mode: "regular" | "fullscreen";
|
|
74
|
+
readonly hardwareCursor: boolean;
|
|
75
|
+
readonly wheelScrollLines?: WheelScrollLines;
|
|
76
|
+
readonly terminal?: PiTuiTerminalPort;
|
|
77
|
+
readonly input?: OwnedUiShellPresentationOptions["input"];
|
|
78
|
+
readonly backgroundSettings?: OwnedUiBackgroundSettingsPort;
|
|
79
|
+
readonly quitOutro?: OwnedUiShellPresentationOptions["quitOutro"];
|
|
80
|
+
}
|
|
81
|
+
/** The terminal a session's shell options call for: its layout, TUI mode, cursor, and presentation seams. */
|
|
82
|
+
export declare function sessionTerminalHostOptions(options: OwnedUiSessionShellOptions): OwnedUiTerminalHostOptions;
|
|
83
|
+
/**
|
|
84
|
+
* Owns the process terminal: the Pi TUI runtime, damage-aware presentation, pointer reporting, owned-route
|
|
85
|
+
* overlays, the quit presentation, and restoration. It shows exactly one active presenter; the runtime's root
|
|
86
|
+
* delegates to it, and a presenter switch starts a new presentation epoch so its first frame paints in full.
|
|
87
|
+
*/
|
|
88
|
+
export declare class OwnedUiTerminalHost implements OwnedUiTerminalHostContract<OwnedUiHostedPresenter> {
|
|
89
|
+
#private;
|
|
90
|
+
readonly runtime: PiTuiRuntimeAdapter;
|
|
91
|
+
constructor(options: OwnedUiTerminalHostOptions);
|
|
92
|
+
get damagePresentationDecision(): PiTuiDamageDecision | null;
|
|
93
|
+
/** Registers a presenter and returns its terminal handle. The presenter paints only once attached. */
|
|
94
|
+
connect(presenter: OwnedUiHostedPresenter): OwnedUiPresenterTerminal;
|
|
95
|
+
/** Forgets a presenter whose construction failed before it could be attached or released. */
|
|
96
|
+
disconnect(presenter: OwnedUiHostedPresenter): void;
|
|
97
|
+
attach(presenter: OwnedUiHostedPresenter): void;
|
|
98
|
+
active(): OwnedUiHostedPresenter | null;
|
|
99
|
+
requestRender(from: OwnedUiHostedPresenter, force?: boolean): void;
|
|
100
|
+
start(): void;
|
|
101
|
+
/** Closes one presenter. Closing the last one ends the terminal. */
|
|
102
|
+
close(presenter: OwnedUiHostedPresenter): Promise<void>;
|
|
103
|
+
dispose(): Promise<void>;
|
|
104
|
+
}
|
|
105
|
+
export {};
|
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
import { MOUSE_TRACKING_OFF, MOUSE_TRACKING_ON, parseMouseInput } from "../../ui/components/mouse.js";
|
|
2
|
+
import { readVisibleHyperlinks } from "../../ui/components/visible-hyperlinks.js";
|
|
3
|
+
import { piCanvasBackgroundAnsi } from "../../integrations/pi/components/theme.js";
|
|
4
|
+
import { DamageAwareTerminalAdapter, } from "../../integrations/pi/tui-runtime/damage-aware-terminal.js";
|
|
5
|
+
import { PiTuiRuntimeAdapter } from "../../integrations/pi/tui-runtime/adapter.js";
|
|
6
|
+
import { classifyPiTuiInput } from "../../integrations/pi/tui-runtime/input-presentation-coordinator.js";
|
|
7
|
+
import { pinnedLayoutRoot, } from "./session-shell-root.js";
|
|
8
|
+
/** The terminal a session's shell options call for: its layout, TUI mode, cursor, and presentation seams. */
|
|
9
|
+
export function sessionTerminalHostOptions(options) {
|
|
10
|
+
const { backend, sessionLayout } = options.engine;
|
|
11
|
+
const { terminal, input, backgroundSettings, quitOutro } = options.presentation ?? {};
|
|
12
|
+
const customViewport = sessionLayout === "custom-viewport";
|
|
13
|
+
const settings = backend.settings.snapshot();
|
|
14
|
+
return {
|
|
15
|
+
layout: customViewport ? "custom-viewport" : "pinned",
|
|
16
|
+
// Invariant: bare A1 owns a bounded viewport and therefore always runs on the alternate
|
|
17
|
+
// fullscreen surface. The pinned comparison profiles still honor Pi's mode.
|
|
18
|
+
mode: customViewport ? "fullscreen" : backend.identity.disposed ? "regular" : settings.tuiMode,
|
|
19
|
+
hardwareCursor: backend.session.view().terminal.hardwareCursor,
|
|
20
|
+
...(customViewport ? {} : { wheelScrollLines: settings.fullscreenWheelScrollLines }),
|
|
21
|
+
...(terminal === undefined ? {} : { terminal }),
|
|
22
|
+
...(input === undefined ? {} : { input }),
|
|
23
|
+
...(customViewport && backgroundSettings !== undefined ? { backgroundSettings } : {}),
|
|
24
|
+
...(quitOutro === undefined ? {} : { quitOutro }),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Owns the process terminal: the Pi TUI runtime, damage-aware presentation, pointer reporting, owned-route
|
|
29
|
+
* overlays, the quit presentation, and restoration. It shows exactly one active presenter; the runtime's root
|
|
30
|
+
* delegates to it, and a presenter switch starts a new presentation epoch so its first frame paints in full.
|
|
31
|
+
*/
|
|
32
|
+
export class OwnedUiTerminalHost {
|
|
33
|
+
runtime;
|
|
34
|
+
#customViewport;
|
|
35
|
+
#damageTerminal;
|
|
36
|
+
#quitOutro;
|
|
37
|
+
#presenters = new Set();
|
|
38
|
+
#overlays = new Map();
|
|
39
|
+
#removeViewportPreInput;
|
|
40
|
+
#unsubscribeBackground;
|
|
41
|
+
#active = null;
|
|
42
|
+
#focused = false;
|
|
43
|
+
#pointerReporting = false;
|
|
44
|
+
#started = false;
|
|
45
|
+
#ending;
|
|
46
|
+
constructor(options) {
|
|
47
|
+
this.#customViewport = options.layout === "custom-viewport";
|
|
48
|
+
this.#quitOutro = options.quitOutro;
|
|
49
|
+
const input = options.input;
|
|
50
|
+
let damageTerminal;
|
|
51
|
+
const surface = {
|
|
52
|
+
render: width => this.#active?.render(width) ?? [],
|
|
53
|
+
handleInput: data => this.#active?.handleInput(data),
|
|
54
|
+
invalidate: () => this.#active?.invalidate(),
|
|
55
|
+
setFocused: focused => {
|
|
56
|
+
this.#focused = focused;
|
|
57
|
+
this.#active?.setFocused(focused);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
const runtimeOptions = {
|
|
61
|
+
root: surface,
|
|
62
|
+
mode: options.mode,
|
|
63
|
+
...(this.#customViewport ? {
|
|
64
|
+
// Invariant: the owned shell enables and routes mouse reports itself. Pi's
|
|
65
|
+
// enclosing fullscreen renderer must never establish a competing white selection.
|
|
66
|
+
mouse: false,
|
|
67
|
+
consumeUnhandledMouse: true,
|
|
68
|
+
onOverlayGeometry: surfaces => this.#active?.setOverlaySurfaces(surfaces),
|
|
69
|
+
decorateTerminal: (terminal) => {
|
|
70
|
+
damageTerminal = new DamageAwareTerminalAdapter(terminal, {
|
|
71
|
+
regionalScroll: process.env.TERM !== "dumb",
|
|
72
|
+
inspectHyperlinks: readVisibleHyperlinks,
|
|
73
|
+
onResize: () => this.#active?.noteImmediatePresentation(),
|
|
74
|
+
onHyperlinkCleanupRequired: () => this.runtime?.requestRender(true),
|
|
75
|
+
});
|
|
76
|
+
return damageTerminal;
|
|
77
|
+
},
|
|
78
|
+
...(input?.coordination === false ? {} : {
|
|
79
|
+
inputCoordination: {
|
|
80
|
+
classify: (data, focusedOverlay) => classifyPiTuiInput(data, focusedOverlay ?? this.#active?.inputCoordinationSurface() ?? "opaque"),
|
|
81
|
+
onReceipt: () => this.#active?.noteImmediatePresentation(),
|
|
82
|
+
...(input?.scheduler === undefined ? {} : { scheduler: input.scheduler }),
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
} : { layoutRoot: pinnedLayoutRoot(this.#delegatingLayoutParts()) }),
|
|
86
|
+
...(input?.onEvent === undefined ? {} : {
|
|
87
|
+
inputDiagnostics: {
|
|
88
|
+
onEvent: input.onEvent,
|
|
89
|
+
...(input.now === undefined ? {} : { now: input.now }),
|
|
90
|
+
},
|
|
91
|
+
}),
|
|
92
|
+
...(options.terminal === undefined ? {} : { terminal: options.terminal }),
|
|
93
|
+
hardwareCursor: options.hardwareCursor,
|
|
94
|
+
...(options.wheelScrollLines === undefined ? {} : { wheelScrollLines: options.wheelScrollLines }),
|
|
95
|
+
};
|
|
96
|
+
this.runtime = new PiTuiRuntimeAdapter(runtimeOptions);
|
|
97
|
+
this.#damageTerminal = damageTerminal ?? null;
|
|
98
|
+
const background = this.#customViewport ? options.backgroundSettings : undefined;
|
|
99
|
+
if (background !== undefined)
|
|
100
|
+
this.#damageTerminal?.setCanvasBackground(piCanvasBackgroundAnsi(background.snapshot()));
|
|
101
|
+
this.#unsubscribeBackground = background === undefined || this.#damageTerminal === null ? () => { } : background.onChange(style => {
|
|
102
|
+
if (this.#damageTerminal?.setCanvasBackground(piCanvasBackgroundAnsi(style)))
|
|
103
|
+
this.runtime.requestRender(true);
|
|
104
|
+
});
|
|
105
|
+
this.#removeViewportPreInput = this.#customViewport
|
|
106
|
+
? this.runtime.addPreInputListener(data => this.#active?.handleViewportInput(data))
|
|
107
|
+
: () => { };
|
|
108
|
+
}
|
|
109
|
+
get damagePresentationDecision() {
|
|
110
|
+
return this.#damageTerminal?.lastDecision ?? null;
|
|
111
|
+
}
|
|
112
|
+
/** Registers a presenter and returns its terminal handle. The presenter paints only once attached. */
|
|
113
|
+
connect(presenter) {
|
|
114
|
+
if (this.#ending !== undefined)
|
|
115
|
+
throw new Error("terminal host has ended");
|
|
116
|
+
this.#presenters.add(presenter);
|
|
117
|
+
return this.#handle(presenter);
|
|
118
|
+
}
|
|
119
|
+
/** Forgets a presenter whose construction failed before it could be attached or released. */
|
|
120
|
+
disconnect(presenter) {
|
|
121
|
+
if (this.#active === presenter)
|
|
122
|
+
this.#active = null;
|
|
123
|
+
this.#forget(presenter);
|
|
124
|
+
}
|
|
125
|
+
attach(presenter) {
|
|
126
|
+
if (!this.#presenters.has(presenter))
|
|
127
|
+
throw new Error("presenter is not connected to this terminal host");
|
|
128
|
+
if (this.#active === presenter)
|
|
129
|
+
return;
|
|
130
|
+
const previous = this.#active;
|
|
131
|
+
previous?.setFocused(false);
|
|
132
|
+
this.#active = presenter;
|
|
133
|
+
if (previous !== null)
|
|
134
|
+
for (const overlay of this.#overlays.get(previous) ?? [])
|
|
135
|
+
overlay.syncVisibility();
|
|
136
|
+
for (const overlay of this.#overlays.get(presenter) ?? [])
|
|
137
|
+
overlay.syncVisibility();
|
|
138
|
+
presenter.setFocused(this.#focused);
|
|
139
|
+
// Invariant: the incoming presenter's frame ids are its own; the epoch keeps the damage terminal from
|
|
140
|
+
// reading them as stale and from reconciling them against the previous presenter's cells.
|
|
141
|
+
this.#damageTerminal?.invalidatePresentation();
|
|
142
|
+
this.runtime.invalidate();
|
|
143
|
+
this.runtime.requestRender(true);
|
|
144
|
+
presenter.activated();
|
|
145
|
+
}
|
|
146
|
+
active() {
|
|
147
|
+
return this.#active;
|
|
148
|
+
}
|
|
149
|
+
requestRender(from, force = false) {
|
|
150
|
+
if (from === this.#active)
|
|
151
|
+
this.runtime.requestRender(force);
|
|
152
|
+
}
|
|
153
|
+
start() {
|
|
154
|
+
if (this.#started || this.#ending !== undefined)
|
|
155
|
+
return;
|
|
156
|
+
this.#started = true;
|
|
157
|
+
this.runtime.start();
|
|
158
|
+
if (this.#customViewport)
|
|
159
|
+
this.#setPointerReporting(true);
|
|
160
|
+
}
|
|
161
|
+
/** Closes one presenter. Closing the last one ends the terminal. */
|
|
162
|
+
close(presenter) {
|
|
163
|
+
if (this.#ending !== undefined || !this.#presenters.has(presenter))
|
|
164
|
+
return this.#ending ?? Promise.resolve();
|
|
165
|
+
if (this.#presenters.size === 1)
|
|
166
|
+
return this.dispose();
|
|
167
|
+
return this.#closeOne(presenter);
|
|
168
|
+
}
|
|
169
|
+
// Invariant: one teardown per host, starting on a microtask so never inside the event listener that requested it.
|
|
170
|
+
dispose() {
|
|
171
|
+
this.#ending ??= Promise.resolve().then(() => this.#end());
|
|
172
|
+
return this.#ending;
|
|
173
|
+
}
|
|
174
|
+
async #closeOne(presenter) {
|
|
175
|
+
const release = presenter.release({ mode: this.runtime.mode, columns: this.runtime.viewport().columns });
|
|
176
|
+
this.#forget(presenter);
|
|
177
|
+
if (this.#active === presenter)
|
|
178
|
+
this.#active = null;
|
|
179
|
+
const failures = [...release.failures, ...await release.settle()];
|
|
180
|
+
if (failures.length > 0)
|
|
181
|
+
throw new AggregateError(failures, "Owned UI disposal failed");
|
|
182
|
+
}
|
|
183
|
+
async #end() {
|
|
184
|
+
const failures = [];
|
|
185
|
+
const attempt = (action) => { try {
|
|
186
|
+
action();
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
failures.push(error);
|
|
190
|
+
} };
|
|
191
|
+
// Invariant: the outro frame is what the terminal shows now, before any cleanup writes.
|
|
192
|
+
const outroFrame = this.#captureQuitOutroFrame();
|
|
193
|
+
attempt(() => this.#setPointerReporting(false, true));
|
|
194
|
+
attempt(() => this.#removeViewportPreInput());
|
|
195
|
+
attempt(() => this.#unsubscribeBackground());
|
|
196
|
+
const terminal = { mode: this.runtime.mode, columns: this.runtime.viewport().columns };
|
|
197
|
+
const active = this.#active;
|
|
198
|
+
const releases = [];
|
|
199
|
+
let exitText = "";
|
|
200
|
+
for (const presenter of [...this.#presenters]) {
|
|
201
|
+
try {
|
|
202
|
+
const release = presenter.release(terminal);
|
|
203
|
+
releases.push(release);
|
|
204
|
+
failures.push(...release.failures);
|
|
205
|
+
if (presenter === active)
|
|
206
|
+
exitText = release.exitText;
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
failures.push(error);
|
|
210
|
+
}
|
|
211
|
+
this.#forget(presenter);
|
|
212
|
+
}
|
|
213
|
+
this.#active = null;
|
|
214
|
+
// Invariant: from here to the leave nothing but the outro paints. A throttled frame the
|
|
215
|
+
// renderer still has queued would otherwise land during the stop-time input drain and
|
|
216
|
+
// flash the prompt and footer, whether or not an effect plays.
|
|
217
|
+
attempt(() => this.#freezeQuitPresentation());
|
|
218
|
+
await this.#playQuitOutro(outroFrame);
|
|
219
|
+
// Invariant: terminal restoration precedes any potentially stalled session teardown. The
|
|
220
|
+
// fullscreen leave preserves the screen: the pinned runtime never dumps its final document
|
|
221
|
+
// into the parent terminal, so only the configured exit text follows the leave.
|
|
222
|
+
await this.runtime.dispose({ preserveScreen: this.runtime.mode === "fullscreen" }).catch(error => failures.push(error));
|
|
223
|
+
for (const release of releases)
|
|
224
|
+
failures.push(...await release.settle());
|
|
225
|
+
if (failures.length > 0)
|
|
226
|
+
throw new AggregateError(failures, "Owned UI disposal failed");
|
|
227
|
+
if (exitText.length > 0)
|
|
228
|
+
this.runtime.writeAfterStop(`${exitText}\n`);
|
|
229
|
+
}
|
|
230
|
+
#forget(presenter) {
|
|
231
|
+
this.#presenters.delete(presenter);
|
|
232
|
+
for (const overlay of this.#overlays.get(presenter) ?? [])
|
|
233
|
+
overlay.hide();
|
|
234
|
+
this.#overlays.delete(presenter);
|
|
235
|
+
}
|
|
236
|
+
#handle(presenter) {
|
|
237
|
+
const runtime = this.runtime;
|
|
238
|
+
const attached = () => this.#active === presenter;
|
|
239
|
+
return {
|
|
240
|
+
get running() { return runtime.active; },
|
|
241
|
+
get attached() { return attached(); },
|
|
242
|
+
get mode() { return runtime.mode; },
|
|
243
|
+
start: () => this.start(),
|
|
244
|
+
requestRender: force => this.requestRender(presenter, force),
|
|
245
|
+
renderNow: () => { if (attached())
|
|
246
|
+
runtime.renderNow(); },
|
|
247
|
+
viewport: () => runtime.viewport(),
|
|
248
|
+
writeControl: data => runtime.writeControl(data),
|
|
249
|
+
hasOverlay: () => runtime.hasOverlay(),
|
|
250
|
+
hasFocusedOverlay: () => runtime.hasFocusedOverlay(),
|
|
251
|
+
showOverlay: (component, options) => this.#showOverlay(presenter, component, options),
|
|
252
|
+
addInputListener: listener => runtime.addInputListener(data => attached() ? listener(data) : undefined),
|
|
253
|
+
armFrame: (descriptor, safety) => {
|
|
254
|
+
if (!attached() || this.#damageTerminal === null)
|
|
255
|
+
return;
|
|
256
|
+
this.#damageTerminal.arm({ ...descriptor, epoch: this.#damageTerminal.presentationEpoch }, safety);
|
|
257
|
+
},
|
|
258
|
+
requestHyperlinkCleanup: rows => { if (attached())
|
|
259
|
+
this.#damageTerminal?.requestHyperlinkCleanup(rows); },
|
|
260
|
+
openRoute: (surface, callbacks) => this.#openRoute(presenter, surface, callbacks),
|
|
261
|
+
// Invariant: an inactive presenter must never hold the active presenter's frames.
|
|
262
|
+
beginPresentationHold: () => attached() ? runtime.beginPresentationHold() : () => { },
|
|
263
|
+
switchMode: mode => runtime.switchMode(mode),
|
|
264
|
+
setTitle: title => { if (attached())
|
|
265
|
+
runtime.setTitle(title); },
|
|
266
|
+
setProgramStatus: status => { if (attached() && runtime.active)
|
|
267
|
+
runtime.setProgramStatus(status); },
|
|
268
|
+
setTerminalProgress: active => { if (attached())
|
|
269
|
+
runtime.setTerminalProgress(active); },
|
|
270
|
+
// Rationale: these follow the engine's settings files, which every session shares; the last write wins.
|
|
271
|
+
setHardwareCursor: enabled => runtime.setHardwareCursor(enabled),
|
|
272
|
+
setClearOnShrink: enabled => runtime.setClearOnShrink(enabled),
|
|
273
|
+
setWheelScrollLines: lines => runtime.setWheelScrollLines(lines),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
#showOverlay(presenter, component, options) {
|
|
277
|
+
const owned = this.#overlays.get(presenter) ?? new Set();
|
|
278
|
+
this.#overlays.set(presenter, owned);
|
|
279
|
+
const overlay = new PresenterOverlay(this.runtime.showOverlay(component, options), () => this.#active === presenter, () => owned.delete(overlay));
|
|
280
|
+
overlay.syncVisibility();
|
|
281
|
+
owned.add(overlay);
|
|
282
|
+
return overlay;
|
|
283
|
+
}
|
|
284
|
+
// Invariant: the pinned layout is mounted once; each region renders whichever presenter is active.
|
|
285
|
+
#delegatingLayoutParts() {
|
|
286
|
+
const part = (name) => ({
|
|
287
|
+
render: width => this.#active?.layoutPart(name).render(width) ?? [],
|
|
288
|
+
invalidate: () => this.#active?.layoutPart(name).invalidate(),
|
|
289
|
+
handleInput: data => this.#active?.layoutPart(name).handleInput?.(data),
|
|
290
|
+
});
|
|
291
|
+
return {
|
|
292
|
+
document: part("document"),
|
|
293
|
+
queued: part("queued"),
|
|
294
|
+
aboveWidgets: part("aboveWidgets"),
|
|
295
|
+
status: part("status"),
|
|
296
|
+
editor: part("editor"),
|
|
297
|
+
belowWidgets: part("belowWidgets"),
|
|
298
|
+
footer: part("footer"),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
// Invariant: pointer reporting is disabled on every path that ends the owning screen.
|
|
302
|
+
#setPointerReporting(enabled, forceOff = false) {
|
|
303
|
+
const effective = forceOff ? false : this.#customViewport || enabled;
|
|
304
|
+
if (this.#pointerReporting === effective)
|
|
305
|
+
return;
|
|
306
|
+
this.#pointerReporting = effective;
|
|
307
|
+
if (!this.runtime.active)
|
|
308
|
+
return;
|
|
309
|
+
this.runtime.writeControl(effective ? MOUSE_TRACKING_ON : MOUSE_TRACKING_OFF);
|
|
310
|
+
}
|
|
311
|
+
#openRoute(presenter, surface, callbacks) {
|
|
312
|
+
const runtime = this.runtime;
|
|
313
|
+
const attached = () => this.#active === presenter;
|
|
314
|
+
// Protocol: any-event reporting: hover and drag are what the screen is driven by, and
|
|
315
|
+
// it also stops the terminal treating a drag as a text selection.
|
|
316
|
+
this.#setPointerReporting(true);
|
|
317
|
+
// Protocol: the interrupt chord is global, so it is watched on raw input rather than
|
|
318
|
+
// through the overlay: the pinned shell handles that key before an overlay
|
|
319
|
+
// ever sees it, which is why an owned screen must not rely on being asked.
|
|
320
|
+
let armedAt = 0;
|
|
321
|
+
let rendered = false;
|
|
322
|
+
let closed = false;
|
|
323
|
+
let overlay;
|
|
324
|
+
let removeSurfacePreInput = () => { };
|
|
325
|
+
let removeInterruptWatch = () => { };
|
|
326
|
+
const closeSurface = (notify) => {
|
|
327
|
+
if (closed)
|
|
328
|
+
return;
|
|
329
|
+
closed = true;
|
|
330
|
+
removeSurfacePreInput();
|
|
331
|
+
removeInterruptWatch();
|
|
332
|
+
this.#setPointerReporting(false);
|
|
333
|
+
overlay?.hide();
|
|
334
|
+
if (notify)
|
|
335
|
+
callbacks.closed(rendered);
|
|
336
|
+
};
|
|
337
|
+
removeInterruptWatch = runtime.addInputListener(data => {
|
|
338
|
+
if (!attached() || !data.includes(INTERRUPT))
|
|
339
|
+
return undefined;
|
|
340
|
+
// Protocol: route hosts decide whether one interrupt closes their app. Raw-input forwarding
|
|
341
|
+
// is required because Pi handles Ctrl+C before the fullscreen overlay receives normal input.
|
|
342
|
+
const consumedBySurface = surface.handleInput(INTERRUPT);
|
|
343
|
+
if (surface.isClosed()) {
|
|
344
|
+
armedAt = 0;
|
|
345
|
+
closeSurface(true);
|
|
346
|
+
return { consume: true };
|
|
347
|
+
}
|
|
348
|
+
if (consumedBySurface) {
|
|
349
|
+
armedAt = 0;
|
|
350
|
+
runtime.requestRender();
|
|
351
|
+
return { consume: true };
|
|
352
|
+
}
|
|
353
|
+
const now = Date.now();
|
|
354
|
+
if (armedAt !== 0 && now - armedAt <= INTERRUPT_CHORD_MS) {
|
|
355
|
+
armedAt = 0;
|
|
356
|
+
closeSurface(true);
|
|
357
|
+
callbacks.exit();
|
|
358
|
+
return { consume: true };
|
|
359
|
+
}
|
|
360
|
+
armedAt = now;
|
|
361
|
+
runtime.requestRender();
|
|
362
|
+
// Invariant: the presented screen owns the chord, so the pinned shell never sees a
|
|
363
|
+
// stray interrupt while it is up.
|
|
364
|
+
return { consume: true };
|
|
365
|
+
});
|
|
366
|
+
// Compatibility: fullscreen Pi owns a fallback text-selection layer before focused overlay
|
|
367
|
+
// components see pointer input. Route every mouse report to the owned screen
|
|
368
|
+
// at the pre-input boundary so dropdowns, value hover, and numeric +/- work,
|
|
369
|
+
// and consume even unhandled reports so settings content is never selected.
|
|
370
|
+
const routeMouse = data => {
|
|
371
|
+
if (!attached())
|
|
372
|
+
return undefined;
|
|
373
|
+
const { events, rest } = parseMouseInput(data);
|
|
374
|
+
if (events.length === 0)
|
|
375
|
+
return undefined;
|
|
376
|
+
for (const event of events)
|
|
377
|
+
surface.handleMouse(event);
|
|
378
|
+
if (surface.isClosed())
|
|
379
|
+
closeSurface(true);
|
|
380
|
+
else
|
|
381
|
+
runtime.requestRender();
|
|
382
|
+
return rest.length === 0 ? { consume: true } : { data: rest };
|
|
383
|
+
};
|
|
384
|
+
removeSurfacePreInput = runtime.addPreInputListener(routeMouse);
|
|
385
|
+
const rows = () => Math.max(1, runtime.viewport().rows);
|
|
386
|
+
const component = {
|
|
387
|
+
render: (width) => {
|
|
388
|
+
const frame = [...surface.render(Math.max(1, width), rows())];
|
|
389
|
+
rendered = true;
|
|
390
|
+
return frame;
|
|
391
|
+
},
|
|
392
|
+
handleInput: (data) => {
|
|
393
|
+
const { events, rest } = parseMouseInput(data);
|
|
394
|
+
for (const event of events)
|
|
395
|
+
surface.handleMouse(event);
|
|
396
|
+
if (rest.length > 0)
|
|
397
|
+
surface.handleInput(rest);
|
|
398
|
+
if (surface.isClosed()) {
|
|
399
|
+
closeSurface(true);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
this.requestRender(presenter);
|
|
403
|
+
},
|
|
404
|
+
invalidate: () => this.requestRender(presenter),
|
|
405
|
+
};
|
|
406
|
+
surface.onRenderRequested(() => this.requestRender(presenter));
|
|
407
|
+
surface.onExitRequested(() => {
|
|
408
|
+
closeSurface(true);
|
|
409
|
+
callbacks.exit();
|
|
410
|
+
});
|
|
411
|
+
overlay = this.#showOverlay(presenter, component, {
|
|
412
|
+
width: "100%",
|
|
413
|
+
maxHeight: "100%",
|
|
414
|
+
anchor: "top-left",
|
|
415
|
+
inputCoordination: "owned",
|
|
416
|
+
});
|
|
417
|
+
return { hide: () => closeSurface(false) };
|
|
418
|
+
}
|
|
419
|
+
// Invariant: the snapshot is synchronous; the outro module itself loads only at quit.
|
|
420
|
+
#captureQuitOutroFrame() {
|
|
421
|
+
const outro = this.#quitOutro;
|
|
422
|
+
if (outro === undefined || !outro.interactive || !this.#customViewport || this.#damageTerminal === null)
|
|
423
|
+
return null;
|
|
424
|
+
if (!this.runtime.active || this.runtime.mode !== "fullscreen")
|
|
425
|
+
return null;
|
|
426
|
+
try {
|
|
427
|
+
if (!outro.snapshot().enabled)
|
|
428
|
+
return null;
|
|
429
|
+
const viewport = this.runtime.viewport();
|
|
430
|
+
return {
|
|
431
|
+
rows: this.#damageTerminal.presentedRows(), columns: viewport.columns, height: viewport.rows,
|
|
432
|
+
settings: {
|
|
433
|
+
effect: QUIT_OUTRO_EFFECT,
|
|
434
|
+
durationMs: QUIT_OUTRO_DURATION_MS,
|
|
435
|
+
canvasBackgroundAnsi: this.#damageTerminal.canvasBackgroundAnsi,
|
|
436
|
+
},
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
#freezeQuitPresentation() {
|
|
444
|
+
if (!this.#customViewport || !this.runtime.active || this.runtime.mode !== "fullscreen")
|
|
445
|
+
return;
|
|
446
|
+
this.runtime.freezePresentation();
|
|
447
|
+
}
|
|
448
|
+
// Rationale: any failure here only skips the effect; restoration always follows.
|
|
449
|
+
async #playQuitOutro(capture) {
|
|
450
|
+
const outro = this.#quitOutro;
|
|
451
|
+
if (capture === null || outro === undefined || !this.runtime.active)
|
|
452
|
+
return;
|
|
453
|
+
try {
|
|
454
|
+
// Rationale: the effects stay off the startup graph; quit is the only time they load.
|
|
455
|
+
const { captureQuitOutroFrame, playQuitOutro } = await import("./quit-outro.js");
|
|
456
|
+
const frame = captureQuitOutroFrame(capture.rows, capture.columns, capture.height);
|
|
457
|
+
if (frame === null || !this.runtime.active)
|
|
458
|
+
return;
|
|
459
|
+
await playQuitOutro(frame, capture.settings.effect, capture.settings.durationMs, {
|
|
460
|
+
write: data => this.runtime.writeControl(data),
|
|
461
|
+
...(capture.settings.canvasBackgroundAnsi === null
|
|
462
|
+
? {}
|
|
463
|
+
: { canvasBackgroundAnsi: capture.settings.canvasBackgroundAnsi }),
|
|
464
|
+
...(outro.now === undefined ? {} : { now: outro.now }),
|
|
465
|
+
...(outro.sleep === undefined ? {} : { sleep: outro.sleep }),
|
|
466
|
+
...(outro.seed === undefined ? {} : { seed: outro.seed }),
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
// Rationale: a failed or interrupted effect must never hold the terminal; restoration follows.
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
/** An overlay that stays hidden while its presenter is not the active one, whatever the presenter asked for. */
|
|
475
|
+
class PresenterOverlay {
|
|
476
|
+
#requestedHidden = false;
|
|
477
|
+
#hidden = false;
|
|
478
|
+
#handle;
|
|
479
|
+
#attached;
|
|
480
|
+
#forget;
|
|
481
|
+
constructor(handle, attached, forget) {
|
|
482
|
+
this.#handle = handle;
|
|
483
|
+
this.#attached = attached;
|
|
484
|
+
this.#forget = forget;
|
|
485
|
+
}
|
|
486
|
+
syncVisibility() {
|
|
487
|
+
if (this.#hidden)
|
|
488
|
+
return;
|
|
489
|
+
this.#handle.setHidden(this.#requestedHidden || !this.#attached());
|
|
490
|
+
}
|
|
491
|
+
hide() {
|
|
492
|
+
if (this.#hidden)
|
|
493
|
+
return;
|
|
494
|
+
this.#hidden = true;
|
|
495
|
+
this.#forget();
|
|
496
|
+
this.#handle.hide();
|
|
497
|
+
}
|
|
498
|
+
setHidden(hidden) {
|
|
499
|
+
this.#requestedHidden = hidden;
|
|
500
|
+
this.syncVisibility();
|
|
501
|
+
}
|
|
502
|
+
isHidden() {
|
|
503
|
+
return this.#hidden || this.#handle.isHidden();
|
|
504
|
+
}
|
|
505
|
+
getBounds() {
|
|
506
|
+
return this.#handle.getBounds();
|
|
507
|
+
}
|
|
508
|
+
focus() {
|
|
509
|
+
if (this.#attached())
|
|
510
|
+
this.#handle.focus();
|
|
511
|
+
}
|
|
512
|
+
unfocus(options) {
|
|
513
|
+
this.#handle.unfocus(options);
|
|
514
|
+
}
|
|
515
|
+
isFocused() {
|
|
516
|
+
return this.#handle.isFocused();
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
const INTERRUPT = "\u0003";
|
|
520
|
+
const INTERRUPT_CHORD_MS = 1_500;
|
|
521
|
+
// Rationale: the outro is not configurable; the switch only decides whether this plan plays.
|
|
522
|
+
const QUIT_OUTRO_EFFECT = "fall";
|
|
523
|
+
const QUIT_OUTRO_DURATION_MS = 800;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { updateSessionRepositoryContext, } from "../features/launch/index.js";
|
|
1
|
+
import { PRIMARY_SESSION_AGENT_ID, updateSessionRepositoryContext, } from "../features/launch/index.js";
|
|
2
2
|
import { PRODUCT_IDENTITY, PRODUCT_TEXT } from "../product-identity.js";
|
|
3
3
|
/** Apply repository context only when invoked by an exact live A1 shell-tool runtime. */
|
|
4
4
|
export async function runSessionContextCommand(request, options = {}) {
|
|
@@ -19,8 +19,11 @@ export async function runSessionContextCommand(request, options = {}) {
|
|
|
19
19
|
if (outcome.kind === "linked")
|
|
20
20
|
stdout(`Session worktree context linked: ${outcome.cwd}\n`);
|
|
21
21
|
if (outcome.kind === "worktrees") {
|
|
22
|
-
for (const entry of outcome.entries)
|
|
23
|
-
|
|
22
|
+
for (const entry of outcome.entries) {
|
|
23
|
+
// Compatibility: the primary agent is the only agent of a single-session runtime, so it stays unnamed.
|
|
24
|
+
const agent = entry.agentId === null || entry.agentId === PRIMARY_SESSION_AGENT_ID ? "" : ` [agent ${entry.agentId}]`;
|
|
25
|
+
stdout(`${entry.status}: ${entry.cwd} (${entry.branch})${agent}\n`);
|
|
26
|
+
}
|
|
24
27
|
}
|
|
25
28
|
return 0;
|
|
26
29
|
}
|