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

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 (31) hide show
  1. package/dist/composition/owned-ui.js +2 -1
  2. package/dist/contracts/owned-ui/model.d.ts +5 -1
  3. package/dist/contracts/owned-ui/validation.js +3 -0
  4. package/dist/features/owned-ui/settings-app.d.ts +1 -0
  5. package/dist/features/owned-ui/settings-app.js +129 -17
  6. package/dist/integrations/pi/components/shell-footer-status.js +32 -8
  7. package/dist/integrations/pi/components/shell-shared-facade.d.ts +2 -0
  8. package/dist/integrations/pi/components/upstream/components/owned-editor.js +2 -2
  9. package/dist/integrations/pi/engine/adapter.js +42 -4
  10. package/dist/integrations/pi/engine/compaction-progress.d.ts +28 -0
  11. package/dist/integrations/pi/engine/compaction-progress.js +96 -0
  12. package/dist/integrations/pi/engine/conformance.d.ts +1 -1
  13. package/dist/integrations/pi/engine/conformance.js +3 -3
  14. package/dist/integrations/pi/engine/session-integration.d.ts +25 -3
  15. package/dist/integrations/pi/engine/session-integration.js +93 -8
  16. package/dist/integrations/pi/session-ui/session-shell-root.js +1 -0
  17. package/dist/integrations/pi/session-ui/session-shell.js +14 -42
  18. package/dist/native/darwin-arm64/manifest.json +1 -1
  19. package/dist/native/linux-x64/manifest.json +1 -1
  20. package/dist/native/win32-x64/manifest.json +2 -2
  21. package/dist/native/win32-x64/process-guardian.exe +0 -0
  22. package/dist/ui/components/list-view.js +5 -6
  23. package/dist/ui/components/surface.d.ts +7 -2
  24. package/dist/ui/components/surface.js +13 -3
  25. package/dist/ui/settings/declarations.d.ts +1 -1
  26. package/dist/ui/settings/declarations.js +13 -2
  27. package/dist/ui/settings/migrations.js +13 -0
  28. package/docs/architecture/ui-reference-provenance.md +2 -2
  29. package/docs/local-worktree-cleanup.md +49 -6
  30. package/docs/openspec-archive-automation.md +1 -1
  31. package/package.json +1 -1
@@ -0,0 +1,96 @@
1
+ /** Expected summary size for a first compaction, when no previous summary on the branch can serve as the estimate. */
2
+ export const DEFAULT_EXPECTED_COMPACTION_SUMMARY_CHARS = 4000;
3
+ /** The percent shown while a compaction is still running never reaches this value. */
4
+ const MAX_RUNNING_PERCENT = 99;
5
+ /**
6
+ * Observes the summarization stream of a compaction through the session agent's public stream
7
+ * function and reports an estimated integer percent. Pi never iterates that stream itself (it
8
+ * reads only its final result), so the observer iterates it in the background between
9
+ * `begin()` and `end()` and returns the same stream object to Pi. Outside that window the
10
+ * wrapper is a pass-through. Returns null when the agent exposes no callable stream function,
11
+ * in which case compaction proceeds without progress.
12
+ */
13
+ export function observeCompactionProgress(session, onProgress) {
14
+ const agent = session.agent;
15
+ if (agent === undefined || typeof agent.streamFunction !== "function")
16
+ return null;
17
+ const original = agent.streamFunction;
18
+ let active = false;
19
+ let disposed = false;
20
+ let generation = 0;
21
+ let streamed = 0;
22
+ let expected = DEFAULT_EXPECTED_COMPACTION_SUMMARY_CHARS;
23
+ let reported = null;
24
+ const report = () => {
25
+ const percent = Math.min(MAX_RUNNING_PERCENT, Math.floor((100 * streamed) / expected));
26
+ if (percent === reported)
27
+ return;
28
+ reported = percent;
29
+ onProgress(percent);
30
+ };
31
+ const wrapped = async (model, context, options) => {
32
+ const stream = await original(model, context, options);
33
+ if (!active || disposed)
34
+ return stream;
35
+ const observed = generation;
36
+ void (async () => {
37
+ report();
38
+ // Invariant: observation never affects the request; a stream that cannot be iterated ends observation only.
39
+ try {
40
+ for await (const event of stream) {
41
+ if (observed !== generation || !active)
42
+ return;
43
+ if (event.type === "text_delta") {
44
+ streamed += event.delta.length;
45
+ report();
46
+ }
47
+ }
48
+ }
49
+ catch {
50
+ return;
51
+ }
52
+ })();
53
+ return stream;
54
+ };
55
+ agent.streamFunction = wrapped;
56
+ return {
57
+ begin() {
58
+ generation += 1;
59
+ streamed = 0;
60
+ reported = null;
61
+ expected = latestCompactionSummaryLength(session) ?? DEFAULT_EXPECTED_COMPACTION_SUMMARY_CHARS;
62
+ active = true;
63
+ },
64
+ end() {
65
+ active = false;
66
+ generation += 1;
67
+ },
68
+ dispose() {
69
+ if (disposed)
70
+ return;
71
+ disposed = true;
72
+ active = false;
73
+ generation += 1;
74
+ if (agent.streamFunction === wrapped)
75
+ agent.streamFunction = original;
76
+ },
77
+ };
78
+ }
79
+ function latestCompactionSummaryLength(session) {
80
+ let entries;
81
+ try {
82
+ entries = session.sessionManager?.getBranch?.() ?? [];
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
88
+ const entry = entries[index];
89
+ if (typeof entry !== "object" || entry === null)
90
+ continue;
91
+ const { type, summary } = entry;
92
+ if (type === "compaction" && typeof summary === "string" && summary.length > 0)
93
+ return summary.length;
94
+ }
95
+ return null;
96
+ }
@@ -6,7 +6,7 @@ export interface PiCapabilityConformanceResult {
6
6
  export declare const REQUIRED_PI_CAPABILITY_OPERATIONS: Readonly<{
7
7
  readonly "public-exports": readonly ["services.create", "session.create", "runtime.create"];
8
8
  readonly "session-lifecycle": readonly ["session.new", "session.resume", "session.rebind", "session.dispose"];
9
- readonly "commands-events": readonly ["prompt", "steer", "followUp", "abort", "compact", "setModel", "setThinkingLevel", "subscribe", "dispose"];
9
+ readonly "commands-events": readonly ["prompt", "steer", "followUp", "clearQueue", "abort", "compact", "setModel", "setThinkingLevel", "subscribe", "dispose"];
10
10
  readonly "models-authentication": readonly ["models.list", "models.refresh", "models.completeSimple", "auth.status", "auth.login", "auth.logout", "auth.cancel"];
11
11
  readonly settings: readonly ["settings.read", "settings.write", "settings.flush"];
12
12
  readonly "resources-extensions": readonly ["resources.discover", "extensions.inline", "extensions.bind", "extensions.reload", "renderers.invoke"];
@@ -8,7 +8,7 @@ import { createWindowsNulCleanupExtension } from "./windows-filesystem-hygiene.j
8
8
  export const REQUIRED_PI_CAPABILITY_OPERATIONS = Object.freeze({
9
9
  "public-exports": ["services.create", "session.create", "runtime.create"],
10
10
  "session-lifecycle": ["session.new", "session.resume", "session.rebind", "session.dispose"],
11
- "commands-events": ["prompt", "steer", "followUp", "abort", "compact", "setModel", "setThinkingLevel", "subscribe", "dispose"],
11
+ "commands-events": ["prompt", "steer", "followUp", "clearQueue", "abort", "compact", "setModel", "setThinkingLevel", "subscribe", "dispose"],
12
12
  "models-authentication": ["models.list", "models.refresh", "models.completeSimple", "auth.status", "auth.login", "auth.logout", "auth.cancel"],
13
13
  settings: ["settings.read", "settings.write", "settings.flush"],
14
14
  "resources-extensions": ["resources.discover", "extensions.inline", "extensions.bind", "extensions.reload", "renderers.invoke"],
@@ -85,7 +85,7 @@ export async function runPiUpgradeConformance() {
85
85
  });
86
86
  const session = created.session;
87
87
  sessionId = session.sessionId;
88
- requireMethods(session, "session commands", ["prompt", "steer", "followUp", "abort", "compact", "setModel", "setThinkingLevel", "subscribe", "dispose"]);
88
+ requireMethods(session, "session commands", ["prompt", "steer", "followUp", "clearQueue", "abort", "compact", "setModel", "setThinkingLevel", "subscribe", "dispose"]);
89
89
  requireMethods(services.modelRuntime, "models/authentication", ["getModels", "getModel", "completeSimple", "checkAuth", "login", "logout", "refresh"]);
90
90
  requireMethods(services.settingsManager, "settings", ["getGlobalSettings", "getProjectSettings", "flush"]);
91
91
  requireMethods(services.resourceLoader, "resources/extensions", ["getExtensions", "getSkills", "getPrompts", "getThemes", "reload"]);
@@ -96,7 +96,7 @@ export async function runPiUpgradeConformance() {
96
96
  catch (error) {
97
97
  throw new PiUpgradeConformanceError("session", error);
98
98
  }
99
- const commandSurface = ["prompt", "steer", "followUp", "abort", "compact", "setModel", "setThinkingLevel", "subscribe", "dispose"];
99
+ const commandSurface = ["prompt", "steer", "followUp", "clearQueue", "abort", "compact", "setModel", "setThinkingLevel", "subscribe", "dispose"];
100
100
  const capabilities = Object.entries(REQUIRED_PI_CAPABILITY_OPERATIONS)
101
101
  .map(([capabilityName, operations]) => capability(capabilityName, operations));
102
102
  validatePiCapabilityResults(VERSION, capabilities);
@@ -1,24 +1,33 @@
1
1
  import type { AgentSession, AgentSessionEvent, PromptOptions } from "../startup-public.js";
2
2
  import { type AgentCommandOutcome, type AgentEvent } from "../../../contracts/agent-engine/index.js";
3
+ type PiPromptImages = NonNullable<PromptOptions["images"]>;
3
4
  export interface PiDocumentedSessionCommands {
4
5
  readonly isStreaming: AgentSession["isStreaming"];
5
6
  readonly isRetrying: AgentSession["isRetrying"];
6
7
  readonly isCompacting: AgentSession["isCompacting"];
7
8
  prompt(text: string, options?: Parameters<AgentSession["prompt"]>[1]): Promise<void>;
8
- steer(text: string): Promise<void>;
9
- followUp(text: string): Promise<void>;
9
+ steer(text: string, images?: PiPromptImages): Promise<void>;
10
+ followUp(text: string, images?: PiPromptImages): Promise<void>;
10
11
  abort(): Promise<void>;
11
12
  abortRetry(): void;
12
13
  abortCompaction(): void;
13
14
  compact(customInstructions?: Parameters<AgentSession["compact"]>[0]): Promise<unknown>;
15
+ clearQueue(): {
16
+ readonly steering: readonly string[];
17
+ readonly followUp: readonly string[];
18
+ };
14
19
  executeBash?(command: string, onChunk: unknown, options: {
15
20
  readonly excludeFromContext: boolean;
16
21
  }): Promise<unknown>;
22
+ /** Optional: extension commands cannot be queued, so they run immediately during compaction as in pinned Pi. */
23
+ readonly extensionRunner?: {
24
+ getCommand(name: string): unknown;
25
+ } | undefined;
17
26
  }
18
27
  export type PiSessionCommand = {
19
28
  readonly type: "prompt" | "steer" | "follow-up";
20
29
  readonly text: string;
21
- readonly images?: NonNullable<PromptOptions["images"]>;
30
+ readonly images?: PiPromptImages;
22
31
  } | {
23
32
  readonly type: "abort" | "retry" | "compact";
24
33
  } | {
@@ -37,9 +46,22 @@ export declare class PiSessionCommandIntegration {
37
46
  private readonly session;
38
47
  constructor(session: PiDocumentedSessionCommands);
39
48
  execute(command: PiSessionCommand): Promise<PiSessionCommandResult>;
49
+ /**
50
+ * Delivers the messages queued during a manual compaction. The session is idle when a manual
51
+ * compaction ends, so nothing would consume the queue: the first message starts one run
52
+ * through the ordinary prompt path with its mode, and the rest are re-queued for that run,
53
+ * whose initial poll injects them in order. Automatic compaction needs no delivery: its
54
+ * continuing run or pending prompt consumes the queue itself. The run is not awaited; when
55
+ * the start is refused before the message is accepted, the queue is restored and the error
56
+ * reported through `onFailure`.
57
+ */
58
+ deliverQueuedAfterCompaction(onFailure: (error: unknown) => void): Promise<void>;
59
+ /** Forgets the attachments kept for queued messages; the queue itself was cleared by the caller. */
60
+ forgetQueuedImages(): void;
40
61
  }
41
62
  export interface PiOrderedEventIntegration {
42
63
  dispose(): void;
43
64
  }
44
65
  export declare function subscribeToPiSessionEvents(session: Pick<AgentSession, "subscribe">, sessionId: string, emit: (event: AgentEvent) => void, malformed: (diagnostic: string) => void): PiOrderedEventIntegration;
45
66
  export declare function convertPiSessionEvent(event: AgentSessionEvent, sessionId: string, sequence: number): AgentEvent | null;
67
+ export {};
@@ -3,6 +3,9 @@ import { AGENT_ENGINE_CONTRACT_VERSION, } from "../../../contracts/agent-engine/
3
3
  export class PiSessionCommandIntegration {
4
4
  session;
5
5
  #lastPrompt = null;
6
+ // Rationale: Pi's queue restore returns text only, so the attachments of messages queued
7
+ // during compaction are kept here until the queue is delivered or cleared.
8
+ #queuedImages = [];
6
9
  constructor(session) {
7
10
  this.session = session;
8
11
  }
@@ -18,22 +21,24 @@ export class PiSessionCommandIntegration {
18
21
  });
19
22
  return { outcome: "completed" };
20
23
  case "steer":
24
+ case "follow-up": {
21
25
  this.#lastPrompt = command.text;
26
+ const mode = command.type === "steer" ? "steer" : "followUp";
27
+ if (this.session.isCompacting && !this.#isExtensionCommand(command.text)) {
28
+ // Compatibility: match interactive Pi: prompt() refuses input during manual compaction,
29
+ // while the engine queue accepts it at any time and delivers it when compaction ends.
30
+ await this.#queue(mode, command.text, command.images);
31
+ return { outcome: "completed" };
32
+ }
22
33
  // Compatibility: match interactive Pi: prompt() owns template/extension expansion and
23
34
  // turns the accepted steering message into the visible user row while
24
35
  // later messages remain in the pending queue.
25
36
  await this.session.prompt(command.text, {
26
- streamingBehavior: "steer",
27
- ...(command.images === undefined ? {} : { images: [...command.images] }),
28
- });
29
- return { outcome: "completed" };
30
- case "follow-up":
31
- this.#lastPrompt = command.text;
32
- await this.session.prompt(command.text, {
33
- streamingBehavior: "followUp",
37
+ streamingBehavior: mode,
34
38
  ...(command.images === undefined ? {} : { images: [...command.images] }),
35
39
  });
36
40
  return { outcome: "completed" };
41
+ }
37
42
  case "abort":
38
43
  if (this.session.isRetrying)
39
44
  this.session.abortRetry();
@@ -61,6 +66,86 @@ export class PiSessionCommandIntegration {
61
66
  }
62
67
  }
63
68
  }
69
+ /**
70
+ * Delivers the messages queued during a manual compaction. The session is idle when a manual
71
+ * compaction ends, so nothing would consume the queue: the first message starts one run
72
+ * through the ordinary prompt path with its mode, and the rest are re-queued for that run,
73
+ * whose initial poll injects them in order. Automatic compaction needs no delivery: its
74
+ * continuing run or pending prompt consumes the queue itself. The run is not awaited; when
75
+ * the start is refused before the message is accepted, the queue is restored and the error
76
+ * reported through `onFailure`.
77
+ */
78
+ async deliverQueuedAfterCompaction(onFailure) {
79
+ if (this.session.isStreaming || this.session.isCompacting)
80
+ return;
81
+ const { steering, followUp } = this.session.clearQueue();
82
+ const queued = [
83
+ ...steering.map(text => ({ mode: "steer", text, images: this.#takeImages(text) })),
84
+ ...followUp.map(text => ({ mode: "followUp", text, images: this.#takeImages(text) })),
85
+ ];
86
+ this.#queuedImages = [];
87
+ const [first, ...rest] = queued;
88
+ if (first === undefined)
89
+ return;
90
+ this.#lastPrompt = first.text;
91
+ let accepted = false;
92
+ const started = this.session.prompt(first.text, {
93
+ streamingBehavior: first.mode,
94
+ preflightResult: success => { accepted = success; },
95
+ ...(first.images === undefined ? {} : { images: [...first.images] }),
96
+ });
97
+ for (const item of rest)
98
+ await this.#queue(item.mode, item.text, item.images);
99
+ started.catch(async (error) => {
100
+ // Security: a run that reached the provider is never resent; only a refused start restores.
101
+ try {
102
+ if (!accepted)
103
+ await this.#restoreQueue(queued);
104
+ }
105
+ finally {
106
+ onFailure(error);
107
+ }
108
+ }).catch(() => undefined);
109
+ }
110
+ /** Forgets the attachments kept for queued messages; the queue itself was cleared by the caller. */
111
+ forgetQueuedImages() {
112
+ this.#queuedImages = [];
113
+ }
114
+ // Rationale: the rest were re-queued before the refused start surfaced, so the queue is rebuilt
115
+ // in the original order; anything queued in between keeps its place after it.
116
+ async #restoreQueue(queued) {
117
+ const { steering, followUp } = this.session.clearQueue();
118
+ const restored = new Set(queued.map(item => item.text));
119
+ const extra = [
120
+ ...steering.filter(text => !restored.has(text)).map(text => ({ mode: "steer", text, images: this.#takeImages(text) })),
121
+ ...followUp.filter(text => !restored.has(text)).map(text => ({ mode: "followUp", text, images: this.#takeImages(text) })),
122
+ ];
123
+ this.#queuedImages = [];
124
+ for (const item of [...queued, ...extra])
125
+ await this.#queue(item.mode, item.text, item.images);
126
+ }
127
+ async #queue(mode, text, images) {
128
+ if (images !== undefined && images.length > 0)
129
+ this.#queuedImages.push({ text, images: [...images] });
130
+ if (mode === "steer")
131
+ await this.session.steer(text, images === undefined ? undefined : [...images]);
132
+ else
133
+ await this.session.followUp(text, images === undefined ? undefined : [...images]);
134
+ }
135
+ #takeImages(text) {
136
+ const index = this.#queuedImages.findIndex(item => item.text === text);
137
+ if (index === -1)
138
+ return undefined;
139
+ const [item] = this.#queuedImages.splice(index, 1);
140
+ return item?.images;
141
+ }
142
+ #isExtensionCommand(text) {
143
+ if (!text.startsWith("/"))
144
+ return false;
145
+ const spaceIndex = text.indexOf(" ");
146
+ const name = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
147
+ return this.session.extensionRunner?.getCommand(name) !== undefined;
148
+ }
64
149
  }
65
150
  export function subscribeToPiSessionEvents(session, sessionId, emit, malformed) {
66
151
  let sequence = 0;
@@ -103,6 +103,7 @@ export class OwnedUiSessionShellRoot {
103
103
  });
104
104
  this.resources = createPiShellLoadedResources(startup.resources ?? [], startup.expanded ?? false);
105
105
  this.#status = createPiShellStatus(view, progressStatusText, handlers);
106
+ this.#status.setProgressPresentation(this.#customViewport ? "custom-viewport" : "pinned");
106
107
  this.#footer = createPiShellFooter(this.#viewWithExtensionStatuses(view), cwd, this.#customViewport ? "a1" : "pi");
107
108
  this.#queued = createPiQueuedInputStatus(view.editor.queuedSubmissions, this.#customViewport ? "custom-viewport" : "pinned");
108
109
  this.editor = createPiShellEditor({
@@ -66,7 +66,6 @@ export class OwnedUiSessionShell {
66
66
  #showImages = true;
67
67
  #imageWidthCells = 80;
68
68
  #fullscreenExitOutput = "transcript";
69
- #compactionQueue = [];
70
69
  #waitingImages = new Map();
71
70
  #lastClearTime = 0;
72
71
  #lastEscapeTime = 0;
@@ -494,8 +493,6 @@ export class OwnedUiSessionShell {
494
493
  model: event.model,
495
494
  });
496
495
  }
497
- if (view.lifecycle === "ready" && this.#compactionQueue.length > 0)
498
- void this.#flushCompactionQueue();
499
496
  if (event.type === "session-lifecycle" && event.lifecycle === "stopped")
500
497
  this.#settleStoppedLifecycle();
501
498
  });
@@ -642,18 +639,8 @@ export class OwnedUiSessionShell {
642
639
  }
643
640
  }
644
641
  }
645
- if (this.view().status.workingMessage?.startsWith("Compacting") === true) {
646
- this.#rememberInput(displayInput, "steer");
647
- this.#compactionQueue.push({
648
- text: input,
649
- draft: displayInput,
650
- type: "steer",
651
- ...(prepared.images.length === 0 ? {} : { images: prepared.images }),
652
- });
653
- this.root.appendWorkflowResult({ command: "compact", outcome: "completed", message: `Queued during compaction: ${input}` });
654
- this.runtime.requestRender();
655
- return { outcome: "completed", diagnostic: null };
656
- }
642
+ // Compatibility: match interactive Pi: input during compaction is queued steering; the engine
643
+ // shows it in the pending rows and delivers it when compaction ends.
657
644
  const type = this.view().lifecycle === "busy" ? "steer" : "prompt";
658
645
  this.#rememberInput(displayInput, type);
659
646
  this.root.resumeViewportFollowing();
@@ -772,15 +759,6 @@ export class OwnedUiSessionShell {
772
759
  if (this.root.editor.getText() === draft)
773
760
  this.root.editor.setText("");
774
761
  this.root.resumeViewportFollowing();
775
- if (this.view().status.workingMessage?.startsWith("Compacting") === true) {
776
- this.#compactionQueue.push({
777
- text,
778
- draft: displayInput,
779
- type: "follow-up",
780
- ...(prepared.images.length === 0 ? {} : { images: prepared.images }),
781
- });
782
- return { outcome: "completed", diagnostic: null };
783
- }
784
762
  return this.#execute({
785
763
  type: "follow-up",
786
764
  correlationId: this.#correlation("follow-up"),
@@ -790,9 +768,8 @@ export class OwnedUiSessionShell {
790
768
  }, displayInput);
791
769
  }
792
770
  restoreQueuedInput() {
793
- const queued = [...this.#waitingImages.keys(), ...this.backend.clearQueuedWorkflows(), ...this.#compactionQueue.map(item => item.draft)];
771
+ const queued = [...this.#waitingImages.keys(), ...this.backend.clearQueuedWorkflows()];
794
772
  this.#cancelWaitingImages();
795
- this.#compactionQueue = [];
796
773
  if (queued.length === 0)
797
774
  return;
798
775
  this.root.editor.setText(queued.join("\n"));
@@ -1394,6 +1371,10 @@ export class OwnedUiSessionShell {
1394
1371
  attempt(() => this.#unsubscribe());
1395
1372
  attempt(() => this.#dialogHandle?.hide());
1396
1373
  attempt(() => this.#extensionBridge.dispose());
1374
+ // Invariant: from here to the leave nothing but the outro paints. A throttled frame the
1375
+ // renderer still has queued would otherwise land during the stop-time input drain and
1376
+ // flash the prompt and footer, whether or not an effect plays.
1377
+ attempt(() => this.#freezeQuitPresentation());
1397
1378
  await this.#playQuitOutro(outroFrame);
1398
1379
  // Invariant: terminal restoration precedes any potentially stalled backend teardown. The
1399
1380
  // fullscreen leave preserves the screen: the pinned runtime never dumps its final document
@@ -1415,8 +1396,8 @@ export class OwnedUiSessionShell {
1415
1396
  if (!this.runtime.active || this.runtime.mode !== "fullscreen")
1416
1397
  return null;
1417
1398
  try {
1418
- const { effect, durationMs } = outro.snapshot();
1419
- if (effect === "off")
1399
+ const { enabled, effect, durationMs } = outro.snapshot();
1400
+ if (!enabled)
1420
1401
  return null;
1421
1402
  const viewport = this.runtime.viewport();
1422
1403
  return { rows: this.#damageTerminal.presentedRows(), columns: viewport.columns, height: viewport.rows, settings: { effect, durationMs } };
@@ -1425,6 +1406,11 @@ export class OwnedUiSessionShell {
1425
1406
  return null;
1426
1407
  }
1427
1408
  }
1409
+ #freezeQuitPresentation() {
1410
+ if (!this.#customViewport || !this.runtime.active || this.runtime.mode !== "fullscreen")
1411
+ return;
1412
+ this.runtime.freezePresentation();
1413
+ }
1428
1414
  // Rationale: any failure here only skips the effect; restoration always follows.
1429
1415
  async #playQuitOutro(capture) {
1430
1416
  const outro = this.#quitOutro;
@@ -1436,7 +1422,6 @@ export class OwnedUiSessionShell {
1436
1422
  const frame = captureQuitOutroFrame(capture.rows, capture.columns, capture.height);
1437
1423
  if (frame === null || !this.runtime.active)
1438
1424
  return;
1439
- this.runtime.freezePresentation();
1440
1425
  await playQuitOutro(frame, capture.settings.effect, capture.settings.durationMs, {
1441
1426
  write: data => this.runtime.writeControl(data),
1442
1427
  ...(outro.now === undefined ? {} : { now: outro.now }),
@@ -1765,19 +1750,6 @@ export class OwnedUiSessionShell {
1765
1750
  this.root.setInputSurface(null);
1766
1751
  this.runtime.requestRender();
1767
1752
  }
1768
- async #flushCompactionQueue() {
1769
- const queued = this.#compactionQueue;
1770
- this.#compactionQueue = [];
1771
- for (const item of queued) {
1772
- await this.#execute({
1773
- type: item.type,
1774
- correlationId: this.#correlation(`compaction-${item.type}`),
1775
- sessionId: this.backend.sessionId,
1776
- text: item.text,
1777
- ...(item.images === undefined ? {} : { images: item.images }),
1778
- }, item.draft);
1779
- }
1780
- }
1781
1753
  async #execute(command, draft) {
1782
1754
  if (draft === undefined)
1783
1755
  return this.backend.execute(command);
@@ -5,7 +5,7 @@
5
5
  "platform": "darwin",
6
6
  "architecture": "arm64",
7
7
  "capability": "supported",
8
- "builtAt": "2026-09-17T10:19:32.993Z",
8
+ "builtAt": "2026-09-17T17:09:52.350Z",
9
9
  "artifact": {
10
10
  "filename": "process-guardian",
11
11
  "sha256": "9db1726bbe3fc2e8292f2ead1e7e9d0fd4d7d0dc9217b372582bf354b0f565dc",
@@ -5,7 +5,7 @@
5
5
  "platform": "linux",
6
6
  "architecture": "x64",
7
7
  "capability": "supported",
8
- "builtAt": "2026-09-17T10:19:33.794Z",
8
+ "builtAt": "2026-09-17T17:09:48.434Z",
9
9
  "artifact": {
10
10
  "filename": "process-guardian",
11
11
  "sha256": "d8cda6b0c7cb36c0cc41e90802aceebf6c02eaebbc08a83d7d963d2e918dbd7a",
@@ -5,10 +5,10 @@
5
5
  "platform": "win32",
6
6
  "architecture": "x64",
7
7
  "capability": "supported",
8
- "builtAt": "2026-09-17T10:20:20.745Z",
8
+ "builtAt": "2026-09-17T17:10:27.708Z",
9
9
  "artifact": {
10
10
  "filename": "process-guardian.exe",
11
- "sha256": "049f0659bfe5d506a900d07e219c6a96480e8495dede8375c7ea5f9e5dc1990d",
11
+ "sha256": "7f414d3e5005949040b86ae78a4f73375ac5773c6ad9f6a3b3f22e7d9f7d5b11",
12
12
  "size": 177664
13
13
  },
14
14
  "provenance": {
@@ -29,14 +29,13 @@ export function renderListRow(row, state, valueColumn, width, theme) {
29
29
  ? `${theme.fg("accent", cursor)}${theme.fg("accent", labelPadded)}`
30
30
  : `${cursor}${theme.plain(labelPadded)}`;
31
31
  const gap = Math.max(2, valueColumn - displayWidth(leftRaw));
32
- // Compatibility: pinned SettingsList gives the selected label and value the same accent
33
- // role. Pointer hover may brighten an unselected value without changing the
34
- // keyboard selection.
32
+ // Rationale: a declared difference from pinned SettingsList, which paints the selected
33
+ // value in the accent too. Here only the cursor and label carry the selection; the
34
+ // value reads the same on every row, and pointer hover brightens it without moving
35
+ // the keyboard selection.
35
36
  const valueHovered = state.hovered && state.region !== "label";
36
37
  const stepper = row.stepper !== undefined && valueHovered;
37
- const value = state.selected
38
- ? theme.fg("accent", row.value)
39
- : valueHovered ? theme.plain(row.value) : theme.fg("muted", row.value);
38
+ const value = valueHovered ? theme.plain(row.value) : theme.fg("muted", row.value);
40
39
  const minus = stepper
41
40
  ? row.stepper?.lower === true
42
41
  ? state.region === "minus" ? theme.plain("- ") : theme.fg("dim", "- ")
@@ -1,12 +1,17 @@
1
- import { type ScrollbarGeometry } from "./scrollbar.js";
1
+ import { type ScrollbarGeometry, type ScrollbarPresentation } from "./scrollbar.js";
2
2
  import type { UiTheme } from "./theme.js";
3
3
  /** Columns the rail occupies: its own, plus the gap before it. */
4
4
  export declare const RAIL_COLUMNS = 2;
5
5
  export interface RailOptions {
6
6
  /** Rows at the top the rail does not run beside, such as a sticky header. */
7
7
  readonly topInset?: number;
8
+ /** How the rail shows. Absent draws the thin rail whenever the content overflows. */
9
+ readonly presentation?: ScrollbarPresentation;
8
10
  }
9
- /** Draws the rail beside each row, padding the rows to a common width first. */
11
+ /**
12
+ * Draws the rail beside each row, padding the rows to a common width first.
13
+ * A presentation that reserves no space returns the rows untouched.
14
+ */
10
15
  export declare function withScrollbarRail(lines: readonly string[], geometry: ScrollbarGeometry | null, contentWidth: number, theme: UiTheme, options?: RailOptions): readonly string[];
11
16
  /**
12
17
  * What a list shows instead of rows: a mark and a line, both quiet, sitting in
@@ -3,13 +3,23 @@ import { displayWidth } from "./text.js";
3
3
  // Rationale: shared list chrome belongs here rather than in individual screens.
4
4
  /** Columns the rail occupies: its own, plus the gap before it. */
5
5
  export const RAIL_COLUMNS = 2;
6
- /** Draws the rail beside each row, padding the rows to a common width first. */
6
+ /**
7
+ * Draws the rail beside each row, padding the rows to a common width first.
8
+ * A presentation that reserves no space returns the rows untouched.
9
+ */
7
10
  export function withScrollbarRail(lines, geometry, contentWidth, theme, options = {}) {
8
11
  const inset = options.topInset ?? 0;
12
+ const presentation = options.presentation
13
+ ?? { visible: geometry !== null, reservesSpace: true, trackGlyph: "│", thumbGlyph: "│" };
14
+ if (!presentation.reservesSpace)
15
+ return lines;
16
+ const drawn = presentation.visible && geometry !== null;
9
17
  return lines.map((line, offset) => {
10
- const cell = offset < inset || geometry === null
18
+ const cell = offset < inset || !drawn
11
19
  ? " "
12
- : isThumbRow(geometry, offset - inset) ? theme.fg("accent", "│") : theme.fg("dim", "│");
20
+ : isThumbRow(geometry, offset - inset)
21
+ ? theme.fg("accent", presentation.thumbGlyph)
22
+ : theme.fg("dim", presentation.trackGlyph);
13
23
  return `${pad(line, contentWidth)} ${cell}`;
14
24
  });
15
25
  }
@@ -1,4 +1,4 @@
1
- export declare const OWNED_UI_SETTINGS_VERSION = 5;
1
+ export declare const OWNED_UI_SETTINGS_VERSION = 6;
2
2
  export type OwnedUiSettingValue = string | number | boolean;
3
3
  export type OwnedUiSettingApplication = "live" | "restart";
4
4
  export interface OwnedUiSettingDeclaration {
@@ -1,11 +1,22 @@
1
- export const OWNED_UI_SETTINGS_VERSION = 5;
1
+ export const OWNED_UI_SETTINGS_VERSION = 6;
2
2
  const MAX_ID_LENGTH = 64;
3
3
  const ID_PATTERN = /^[a-z][a-z0-9]*(?:[A-Z][a-z0-9]*)*$/;
4
+ /** Declared first so the settings screen opens on it; sections follow first-declaration order. */
5
+ const GENERIC_SECTION = Object.freeze({ id: "generic", title: "Generic" });
4
6
  const SCROLL_SECTION = Object.freeze({ id: "scroll", title: "Scroll" });
5
7
  const QUIT_SECTION = Object.freeze({ id: "quit", title: "Quit" });
6
8
  /** Playback lengths the quit outro offers, in milliseconds. */
7
9
  export const QUIT_EFFECT_DURATIONS_MS = Object.freeze(Array.from({ length: 18 }, (_, index) => 300 + index * 100));
8
10
  export const OWNED_UI_SETTING_DECLARATIONS = Object.freeze([
11
+ Object.freeze({
12
+ id: "quitAnimation",
13
+ label: "Exit animation",
14
+ section: GENERIC_SECTION,
15
+ description: "Play the quit effect when the session quits. Off returns to the terminal immediately.",
16
+ application: "live",
17
+ defaultValue: true,
18
+ allowedValues: Object.freeze([true, false]),
19
+ }),
9
20
  Object.freeze({
10
21
  id: "scrollbarAppearance",
11
22
  label: "Scrollbar mode",
@@ -58,7 +69,7 @@ export const OWNED_UI_SETTING_DECLARATIONS = Object.freeze([
58
69
  description: "Animation played over the last screen when the session quits.",
59
70
  application: "live",
60
71
  defaultValue: "fall",
61
- allowedValues: Object.freeze(["fall", "dissolve", "starburst", "waves", "off"]),
72
+ allowedValues: Object.freeze(["fall", "dissolve", "starburst", "waves"]),
62
73
  }),
63
74
  Object.freeze({
64
75
  id: "quitEffectDurationMs",
@@ -36,6 +36,19 @@ export const OWNED_UI_SETTINGS_MIGRATIONS = Object.freeze([
36
36
  return { ...values };
37
37
  },
38
38
  }),
39
+ Object.freeze({
40
+ to: 6,
41
+ description: "Move the disabled quit effect into the exit-animation toggle.",
42
+ migrate(values) {
43
+ // Invariant: a profile that chose off keeps quitting without an animation; the
44
+ // effect it stored is no longer a choice, so it resolves to the default.
45
+ if (values.quitEffect !== "off")
46
+ return { ...values };
47
+ const migrated = { ...values, quitAnimation: false };
48
+ delete migrated.quitEffect;
49
+ return migrated;
50
+ },
51
+ }),
39
52
  ]);
40
53
  export function assertOwnedUiSettingsMigrations(migrations, currentVersion = OWNED_UI_SETTINGS_VERSION) {
41
54
  const firstProduced = currentVersion - migrations.length + 1;