@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
@@ -120,7 +120,8 @@ function quitOutroSettingsSnapshot(settings) {
120
120
  const effect = settings.value("quitEffect");
121
121
  const durationMs = settings.value("quitEffectDurationMs");
122
122
  return {
123
- effect: effect === "dissolve" || effect === "starburst" || effect === "waves" || effect === "off" ? effect : "fall",
123
+ enabled: settings.value("quitAnimation") !== false,
124
+ effect: effect === "dissolve" || effect === "starburst" || effect === "waves" ? effect : "fall",
124
125
  durationMs: typeof durationMs === "number" ? durationMs : 800,
125
126
  };
126
127
  }
@@ -64,9 +64,11 @@ 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";
67
+ export type OwnedUiQuitEffect = "fall" | "dissolve" | "starburst" | "waves";
68
68
  /** Profile-local quit outro choice, read at the moment bare A1 quits. */
69
69
  export interface OwnedUiQuitOutroSettings {
70
+ /** False leaves the terminal immediately; the effect and duration are then ignored. */
71
+ readonly enabled: boolean;
70
72
  readonly effect: OwnedUiQuitEffect;
71
73
  /** Requested playback length; the player clamps it to its supported range. */
72
74
  readonly durationMs: number;
@@ -124,6 +126,8 @@ export interface OwnedUiFooterView {
124
126
  export interface OwnedUiStatusView {
125
127
  readonly title: string;
126
128
  readonly workingMessage: string | null;
129
+ /** Estimated progress of the shown work state as an integer percent below 100, when the engine can measure it. */
130
+ readonly workingProgress?: number | null;
127
131
  readonly diagnostics: readonly string[];
128
132
  readonly badges: readonly string[];
129
133
  readonly usage?: OwnedUiUsageView;
@@ -347,6 +347,9 @@ export function assertOwnedUiEditorState(editor) {
347
347
  export function assertOwnedUiStatusView(status) {
348
348
  assertBoundedText(status.title, "owned-UI status title", MAX_LABEL_LENGTH);
349
349
  assertOptionalText(status.workingMessage, "owned-UI working message", MAX_MESSAGE_LENGTH);
350
+ if (status.workingProgress !== undefined && status.workingProgress !== null) {
351
+ assertIntegerInRange(status.workingProgress, 0, 99, "owned-UI working progress");
352
+ }
350
353
  assertCollection(status.diagnostics, "owned-UI status diagnostics", MAX_STATUS_DIAGNOSTICS);
351
354
  for (const diagnostic of status.diagnostics) {
352
355
  assertBoundedText(diagnostic, "owned-UI status diagnostic", MAX_MESSAGE_LENGTH);
@@ -14,6 +14,7 @@ export declare class SettingsApp implements UiApp {
14
14
  readonly id = "settings";
15
15
  constructor(session: OwnedUiSettingsSession);
16
16
  onActivate(host: AppHostServices): void;
17
+ onClose(_host: AppHostServices): void;
17
18
  render(rect: PaneRect, host: AppHostServices): readonly string[];
18
19
  onInput(data: string, host: AppHostServices): PaneInputResult;
19
20
  onMouse(event: PaneMouseEvent, _host: AppHostServices): PaneInputResult;
@@ -1,10 +1,15 @@
1
- import { GLOBAL_SCOPE, LineInput, PLAIN_THEME, ShortcutRegistry, assertNoShortcutConflicts, blockJumpTarget, dialogValueColumn, numericValues, RAIL_COLUMNS, renderDialogPanel, renderEmptyState, renderGroupHeader, renderInputRow, renderListRow, renderNote, renderStatusLine, dialogRowAt, menuRowAt, regionAt, renderValueMenu, withScrollbarRail, stepperEnds, steppedValue, valueColumnFor, valueMenuFrame, blockRowSpan, displayWidth, handleLineInputKey, humanizeLabel, humanizeTitle, indexOfKey, isThumbRow, layoutList, moveSelection, rowKey, scrollForSelection, scrollbarGeometry, scrollbarWheelRows, selectableIndexes, } from "../../ui/components/index.js";
1
+ import { GLOBAL_SCOPE, LineInput, PLAIN_THEME, ShortcutRegistry, assertNoShortcutConflicts, blockJumpTarget, dialogValueColumn, numericValues, RAIL_COLUMNS, renderDialogPanel, renderEmptyState, renderGroupHeader, renderInputRow, renderListRow, renderNote, renderStatusLine, dialogRowAt, menuRowAt, regionAt, renderValueMenu, withScrollbarRail, stepperEnds, steppedValue, valueColumnFor, valueMenuFrame, blockRowSpan, displayWidth, handleLineInputKey, humanizeLabel, humanizeTitle, indexOfKey, isThumbRow, layoutList, moveSelection, rowKey, scrollForSelection, scrollForTrackPage, scrollbarGeometry, scrollbarPresentation, ScrollbarRails, scrollbarWheelRows, selectableIndexes, } from "../../ui/components/index.js";
2
2
  import { SETTINGS_APP_ID, SETTINGS_ROUTE } from "./settings-route.js";
3
3
  export { SETTINGS_APP_ID, SETTINGS_ROUTE } from "./settings-route.js";
4
4
  const SCOPE = SETTINGS_APP_ID;
5
5
  /** The panel a setting with parts opens: its own keys, its own hint. */
6
6
  const DIALOG_SCOPE = `${SETTINGS_APP_ID}-parts`;
7
7
  const SCROLLBAR_TOP_INSET = 1;
8
+ /** Identity of the settings list rail in the shared rail state. */
9
+ const RAIL_KEY = "settings";
10
+ // Compatibility: the transcript rail stays lit this long after a scroll, and repaints just after.
11
+ const SCROLL_LINGER_MS = 900;
12
+ const SCROLL_LINGER_REPAINT_MS = 925;
8
13
  const SEARCH_PLACEHOLDER = "search settings";
9
14
  /** What a structured value offers instead of printing itself. */
10
15
  const CONFIGURE = "configure";
@@ -16,8 +21,10 @@ SETTINGS_SHORTCUTS.declare({ key: "shift+up", scope: SCOPE, description: "Previo
16
21
  SETTINGS_SHORTCUTS.declare({ key: "shift+down", scope: SCOPE, description: "Next section", section: "Navigate", hint: { keys: "Shift+↑↓", does: "to jump" } }, "block-down");
17
22
  SETTINGS_SHORTCUTS.declare({ key: "pageUp", scope: SCOPE, description: "Up a page", section: "Navigate" }, "page-up");
18
23
  SETTINGS_SHORTCUTS.declare({ key: "pageDown", scope: SCOPE, description: "Down a page", section: "Navigate" }, "page-down");
19
- SETTINGS_SHORTCUTS.declare({ key: "home", scope: SCOPE, description: "First setting", section: "Navigate" }, "first");
20
- SETTINGS_SHORTCUTS.declare({ key: "end", scope: SCOPE, description: "Last setting", section: "Navigate" }, "last");
24
+ // Compatibility: the same chords the transcript uses for its content boundaries; plain
25
+ // Home and End stay with the search input's cursor.
26
+ SETTINGS_SHORTCUTS.declare({ key: "ctrl+home", scope: SCOPE, description: "First setting", section: "Navigate" }, "first");
27
+ SETTINGS_SHORTCUTS.declare({ key: "ctrl+end", scope: SCOPE, description: "Last setting", section: "Navigate" }, "last");
21
28
  SETTINGS_SHORTCUTS.declare({ key: "enter", scope: SCOPE, description: "Change value", section: "Change", hint: { keys: "Enter/Space", does: "to change" } }, "activate");
22
29
  SETTINGS_SHORTCUTS.declare({ key: "space", scope: SCOPE, description: "Change value", section: "Change", hint: { keys: "Enter/Space", does: "to change" } }, "activate");
23
30
  SETTINGS_SHORTCUTS.declare({ key: "left", scope: SCOPE, description: "Previous value", section: "Change", hint: { keys: "←→", does: "to adjust" } }, "previous-value");
@@ -39,8 +46,11 @@ const KEYS = {
39
46
  "\u001b[C": "right",
40
47
  "\u001b[5~": "pageUp",
41
48
  "\u001b[6~": "pageDown",
42
- "\u001b[H": "home",
43
- "\u001b[F": "end",
49
+ // Protocol: xterm modifier form, then the rxvt Ctrl form of the same chords.
50
+ "\u001b[1;5H": "ctrl+home",
51
+ "\u001b[1;5F": "ctrl+end",
52
+ "\u001b[7^": "ctrl+home",
53
+ "\u001b[8^": "ctrl+end",
44
54
  "\u001b": "escape",
45
55
  "\r": "enter",
46
56
  "\n": "enter",
@@ -76,6 +86,14 @@ export class SettingsApp {
76
86
  #hoverRegion = "label";
77
87
  #frameRows = [];
78
88
  #menuFrame = null;
89
+ // Invariant: rail hover and drag live in the shared keyed state, as the transcript's do.
90
+ #rails = new ScrollbarRails();
91
+ // Rationale: the rail as drawn in the last frame, or null when nothing can be pointed at.
92
+ #railFrame = null;
93
+ // Invariant: a scroll lights the rail until this time; the timer repaints once it has passed.
94
+ #activeUntil = 0;
95
+ #activityTimer;
96
+ #renderedScroll;
79
97
  constructor(session) {
80
98
  this.#session = session;
81
99
  }
@@ -85,6 +103,10 @@ export class SettingsApp {
85
103
  host.requestRender();
86
104
  });
87
105
  }
106
+ onClose(_host) {
107
+ this.#clearActivityTimer();
108
+ this.#rails.clear();
109
+ }
88
110
  render(rect, host) {
89
111
  const theme = host.theme ?? PLAIN_THEME;
90
112
  this.#interruptArmed = host.interruptArmed;
@@ -102,7 +124,17 @@ export class SettingsApp {
102
124
  this.#reveal = undefined;
103
125
  const layout = layoutList(rows, bodyHeight, this.#scroll);
104
126
  this.#scroll = layout.scroll;
105
- const contentWidth = Math.max(0, rect.width - RAIL_COLUMNS);
127
+ const now = Date.now();
128
+ // Rationale: every way of scrolling ends in this frame, so a moved list is noticed here
129
+ // once rather than at each wheel, drag, key, and search branch.
130
+ if (this.#renderedScroll !== undefined && this.#renderedScroll !== layout.scroll)
131
+ this.#noteScrollActivity(host, now);
132
+ this.#renderedScroll = layout.scroll;
133
+ // Invariant: auto and always keep the rail columns while the list fits, so a revealed
134
+ // rail never reflows the rows; hidden gives the columns back to the rows.
135
+ const appearance = this.#scrollbarAppearance();
136
+ const reservesRail = appearance !== "hidden";
137
+ const contentWidth = reservesRail ? Math.max(0, rect.width - RAIL_COLUMNS) : rect.width;
106
138
  const valueColumn = this.#valueColumn(rows);
107
139
  const geometry = scrollbarGeometry({
108
140
  contentLength: rows.length,
@@ -110,6 +142,22 @@ export class SettingsApp {
110
142
  scroll: layout.scroll,
111
143
  trackHeight: Math.max(0, bodyHeight - SCROLLBAR_TOP_INSET),
112
144
  });
145
+ const presentation = scrollbarPresentation({
146
+ geometry,
147
+ appearance,
148
+ style: this.#scrollbarStyle(),
149
+ hovered: this.#rails.isHovered(RAIL_KEY),
150
+ dragging: this.#rails.isDragging(RAIL_KEY),
151
+ activeUntil: this.#activeUntil,
152
+ now,
153
+ });
154
+ this.#railFrame = reservesRail && geometry !== null
155
+ ? {
156
+ rail: { key: RAIL_KEY, column: rect.width, rowStart: SCROLLBAR_TOP_INSET, trackHeight: geometry.trackHeight },
157
+ geometry,
158
+ page: layout.visible,
159
+ }
160
+ : null;
113
161
  const body = [];
114
162
  this.#frameRows = [];
115
163
  if (rows.length === 0) {
@@ -140,8 +188,9 @@ export class SettingsApp {
140
188
  }
141
189
  const withRail = withScrollbarRail(body.slice(0, bodyHeight), geometry, contentWidth, theme, {
142
190
  topInset: SCROLLBAR_TOP_INSET,
191
+ presentation,
143
192
  });
144
- return this.#withMenu([...withRail, ...footer], selected, layout, valueColumn, theme, rect);
193
+ return this.#withMenu([...withRail, ...footer], selected, layout, valueColumn, theme, rect, reservesRail ? RAIL_COLUMNS : 0);
145
194
  }
146
195
  onInput(data, host) {
147
196
  if (this.#structured !== null)
@@ -278,13 +327,16 @@ export class SettingsApp {
278
327
  this.#scroll = Math.max(0, this.#scroll + (event.kind === "wheel-down" ? distance : -distance));
279
328
  return { consumed: true };
280
329
  }
330
+ const rail = this.#railPointer(event);
331
+ if (rail.owned)
332
+ return { consumed: true };
281
333
  const row = this.#frameRows.find(candidate => candidate.screenRow === event.row - 1);
282
334
  const previousKey = this.#hoverKey;
283
335
  const previousRegion = this.#hoverRegion;
284
336
  if (row === undefined) {
285
337
  this.#hoverKey = null;
286
338
  this.#hoverRegion = "label";
287
- return { consumed: event.kind !== "motion", render: previousKey !== null };
339
+ return { consumed: event.kind !== "motion", render: previousKey !== null || rail.changed };
288
340
  }
289
341
  this.#hoverKey = row.key;
290
342
  this.#hoverRegion = regionAt(row, event.column);
@@ -303,9 +355,55 @@ export class SettingsApp {
303
355
  }
304
356
  return { consumed: true };
305
357
  }
306
- const changed = previousKey !== this.#hoverKey || previousRegion !== this.#hoverRegion;
358
+ const changed = previousKey !== this.#hoverKey || previousRegion !== this.#hoverRegion || rail.changed;
307
359
  return { consumed: event.kind !== "motion", render: changed };
308
360
  }
361
+ // Rationale: the rail takes its share of a pointer report first: hover, a thumb drag, or a
362
+ // track page. Owned means the list must not see the report; changed means the rail looks different.
363
+ #railPointer(event) {
364
+ const frame = this.#railFrame;
365
+ const wasHovered = this.#rails.isHovered(RAIL_KEY);
366
+ if (frame === null) {
367
+ this.#rails.clear();
368
+ return { owned: false, changed: wasHovered };
369
+ }
370
+ const pointer = { column: event.column, row: event.row - 1 };
371
+ if (this.#rails.isDragging(RAIL_KEY)) {
372
+ // Invariant: a drag keeps the pointer wherever it goes until the button comes up.
373
+ if (event.kind === "release")
374
+ this.#rails.endDrag();
375
+ else {
376
+ const target = this.#rails.dragTo(frame.rail, frame.geometry, pointer);
377
+ if (target !== null)
378
+ this.#scroll = target;
379
+ }
380
+ return { owned: true, changed: true };
381
+ }
382
+ const over = this.#rails.notePointer([frame.rail], pointer) !== null;
383
+ if (!over)
384
+ return { owned: false, changed: wasHovered };
385
+ // Invariant: the rail is not a row: pointing at it lights nothing in the list.
386
+ this.#hoverKey = null;
387
+ this.#hoverRegion = "label";
388
+ if (event.kind === "press" && !this.#rails.beginDrag(frame.rail, frame.geometry, pointer)) {
389
+ this.#scroll = scrollForTrackPage(frame.geometry, pointer.row - frame.rail.rowStart, this.#scroll, frame.page);
390
+ }
391
+ return { owned: true, changed: true };
392
+ }
393
+ #noteScrollActivity(host, now) {
394
+ this.#activeUntil = Math.max(this.#activeUntil, now + SCROLL_LINGER_MS);
395
+ this.#clearActivityTimer();
396
+ this.#activityTimer = setTimeout(() => {
397
+ this.#activityTimer = undefined;
398
+ host.requestRender();
399
+ }, SCROLL_LINGER_REPAINT_MS);
400
+ this.#activityTimer.unref?.();
401
+ }
402
+ #clearActivityTimer() {
403
+ if (this.#activityTimer !== undefined)
404
+ clearTimeout(this.#activityTimer);
405
+ this.#activityTimer = undefined;
406
+ }
309
407
  #openMenu(rows, selected) {
310
408
  const row = rows[selected];
311
409
  if (row === undefined || row.kind !== "element")
@@ -348,6 +446,7 @@ export class SettingsApp {
348
446
  // thing under the pointer, so it stops looking like it.
349
447
  this.#hoverKey = null;
350
448
  this.#hoverRegion = "label";
449
+ this.#rails.clear();
351
450
  this.#structured = { entry, flags: entry.flags.map(flag => flag.key), index: 0, record };
352
451
  }
353
452
  #structuredKey(data) {
@@ -410,13 +509,15 @@ export class SettingsApp {
410
509
  if (input === null)
411
510
  return { consumed: false };
412
511
  const key = KEYS[data];
413
- if (key === "home" || key === "end") {
512
+ // Rationale: the boundary chords jump through the results; plain Home and End stay with
513
+ // the search cursor, which the shared line input moves below.
514
+ if (key === "ctrl+home" || key === "ctrl+end") {
414
515
  const rows = this.#rows();
415
516
  const selectable = selectableIndexes(rows);
416
- const target = key === "end" ? selectable.at(-1) : selectable[0];
517
+ const target = key === "ctrl+end" ? selectable.at(-1) : selectable[0];
417
518
  if (target !== undefined) {
418
519
  this.#select(rows, target);
419
- if (key === "home")
520
+ if (key === "ctrl+home")
420
521
  this.#scroll = 0;
421
522
  }
422
523
  return { consumed: true };
@@ -502,11 +603,22 @@ export class SettingsApp {
502
603
  return this.#pending.get(`${entry.backend}:${entry.id}`) ?? entry.value;
503
604
  }
504
605
  #scrollbarSpeed() {
606
+ const value = this.#scrollSetting("scrollbarSpeed");
607
+ return isScrollbarSpeed(value) ? value : "normal";
608
+ }
609
+ #scrollbarAppearance() {
610
+ const value = this.#scrollSetting("scrollbarAppearance");
611
+ return value === "always" || value === "hidden" ? value : "auto";
612
+ }
613
+ #scrollbarStyle() {
614
+ return this.#scrollSetting("scrollbarStyle") === "thick" ? "thick" : "thin";
615
+ }
616
+ // Invariant: a Scroll setting reads as the screen shows it: an accepted value first, then the source.
617
+ #scrollSetting(id) {
505
618
  const entry = this.#session.sections()
506
619
  .flatMap(section => section.entries)
507
- .find(candidate => candidate.backend === "a1" && candidate.id === "scrollbarSpeed");
508
- const value = entry === undefined ? this.#session.value("scrollbarSpeed") : this.#shownValue(entry);
509
- return isScrollbarSpeed(value) ? value : "normal";
620
+ .find(candidate => candidate.backend === "a1" && candidate.id === id);
621
+ return entry === undefined ? this.#session.value(id) : this.#shownValue(entry);
510
622
  }
511
623
  #jump(rows, target) {
512
624
  this.#select(rows, target);
@@ -590,7 +702,7 @@ export class SettingsApp {
590
702
  ...(typeof shown === "number" && entry.editable ? { stepper: stepperEnds(range, shown) } : {}),
591
703
  };
592
704
  }
593
- #withMenu(lines, _selected, _layout, valueColumn, theme, rect) {
705
+ #withMenu(lines, _selected, _layout, valueColumn, theme, rect, reservedRight) {
594
706
  const menu = this.#menu;
595
707
  const anchor = menu === null ? undefined : this.#frameRows.find(candidate => candidate.key === menu.anchorKey);
596
708
  if (menu === null || anchor === undefined) {
@@ -605,7 +717,7 @@ export class SettingsApp {
605
717
  const frame = valueMenuFrame(state, { screenRow: anchor.screenRow, valueColumn }, {
606
718
  bodyHeight: lines.length - this.#footerHeight,
607
719
  surfaceWidth: rect.width,
608
- reservedRight: RAIL_COLUMNS,
720
+ reservedRight,
609
721
  });
610
722
  this.#menuFrame = frame;
611
723
  return renderValueMenu(lines, state, frame, theme);
@@ -75,19 +75,29 @@ export function createPiShellStatus(view, formatProgressStatus, runtime) {
75
75
  const statusUi = createTuiFacade(runtime ?? { getColumns: () => 80, getRows: () => 24, requestRender() { } });
76
76
  let workingOverride;
77
77
  let outputPad = PINNED_PI_LAYOUT.outputPad;
78
+ let progressPresentation = "pinned";
78
79
  let placement = statusPlacement(view, workingOverride);
79
- let component = statusComponent(view, statusUi, workingOverride, outputPad, formatProgressStatus, placement);
80
- let signature = statusSignature(view, workingOverride, outputPad, placement);
80
+ const liveStatusText = () => formatProgressStatus(liveWorkingText(view, workingOverride, progressPresentation));
81
+ let component = statusComponent(view, statusUi, outputPad, liveStatusText, placement);
82
+ let signature = statusSignature(view, workingOverride, outputPad, placement, progressPresentation);
81
83
  const rebuild = () => {
82
84
  const nextPlacement = statusPlacement(view, workingOverride);
83
- const nextSignature = statusSignature(view, workingOverride, outputPad, nextPlacement);
85
+ const nextSignature = statusSignature(view, workingOverride, outputPad, nextPlacement, progressPresentation);
84
86
  if (nextSignature === signature)
85
87
  return;
88
+ // Performance: progress ticks change only the live message; the spinner keeps its frame and timer.
89
+ if (placement === "live" && nextPlacement === "live" && component instanceof WorkingStatusIndicator
90
+ && statusSignature(view, workingOverride, outputPad, nextPlacement, progressPresentation, false)
91
+ === statusSignature(view, workingOverride, outputPad, placement, progressPresentation, false)) {
92
+ component.setMessage(liveStatusText());
93
+ signature = nextSignature;
94
+ return;
95
+ }
86
96
  if (component !== undefined && "dispose" in component && typeof component.dispose === "function")
87
97
  component.dispose();
88
98
  placement = nextPlacement;
89
99
  signature = nextSignature;
90
- component = statusComponent(view, statusUi, workingOverride, outputPad, formatProgressStatus, placement);
100
+ component = statusComponent(view, statusUi, outputPad, liveStatusText, placement);
91
101
  };
92
102
  return {
93
103
  render: width => component?.render(width) ?? [],
@@ -107,6 +117,10 @@ export function createPiShellStatus(view, formatProgressStatus, runtime) {
107
117
  outputPad = padding;
108
118
  rebuild();
109
119
  },
120
+ setProgressPresentation(presentation) {
121
+ progressPresentation = presentation;
122
+ rebuild();
123
+ },
110
124
  dispose() {
111
125
  if (component !== undefined && "dispose" in component && typeof component.dispose === "function")
112
126
  component.dispose();
@@ -145,9 +159,18 @@ function statusPlacement(view, workingOverride) {
145
159
  return "dock";
146
160
  return view.status.workingMessage === null ? "hidden" : "dock";
147
161
  }
148
- function statusComponent(view, ui, workingOverride, outputPad, formatProgressStatus, placement) {
162
+ // Rationale: the engine publishes the semantic working word and a separate measured percent; bare A1
163
+ // composes them here, while the pinned route keeps the bare word for comparison with Pi.
164
+ function liveWorkingText(view, workingOverride, progressPresentation) {
165
+ const message = workingOverride ?? view.status.workingMessage ?? "Working";
166
+ const progress = view.status.workingProgress;
167
+ return workingOverride === undefined && progressPresentation === "custom-viewport" && typeof progress === "number"
168
+ ? `${message} (${progress}%)`
169
+ : message;
170
+ }
171
+ function statusComponent(view, ui, outputPad, liveStatusText, placement) {
149
172
  if (placement === "live") {
150
- return new WorkingStatusIndicator(ui, formatProgressStatus(workingOverride ?? view.status.workingMessage ?? "Working"));
173
+ return new WorkingStatusIndicator(ui, liveStatusText());
151
174
  }
152
175
  if (placement === "dock") {
153
176
  if (view.lifecycle === "failed") {
@@ -157,8 +180,9 @@ function statusComponent(view, ui, workingOverride, outputPad, formatProgressSta
157
180
  }
158
181
  return undefined;
159
182
  }
160
- function statusSignature(view, workingOverride, outputPad, placement) {
161
- return `${placement}\u0000${outputPad}\u0000${view.lifecycle}\u0000${workingOverride ?? ""}\u0000${view.status.workingMessage ?? ""}\u0000${view.status.diagnostics.at(-1) ?? ""}`;
183
+ function statusSignature(view, workingOverride, outputPad, placement, progressPresentation, withMessage = true) {
184
+ const message = withMessage ? `${workingOverride ?? ""}\u0000${view.status.workingMessage ?? ""}\u0000${view.status.workingProgress ?? ""}` : "";
185
+ return `${placement}\u0000${outputPad}\u0000${view.lifecycle}\u0000${progressPresentation}\u0000${message}\u0000${view.status.diagnostics.at(-1) ?? ""}`;
162
186
  }
163
187
  function queuedInputText(submissions, presentation) {
164
188
  if (submissions.length === 0)
@@ -79,6 +79,8 @@ export type PiShellStatusPlacement = "live" | "dock" | "hidden";
79
79
  export interface PiShellStatusPort extends PiShellViewComponentPort {
80
80
  setWorkingOverride(message: string | undefined): void;
81
81
  setOutputPad(padding: 0 | 1): void;
82
+ /** Bare A1 shows engine-measured progress beside the working word; the pinned route keeps the bare word. */
83
+ setProgressPresentation(presentation: "pinned" | "custom-viewport"): void;
82
84
  /** Semantic row placement; callers must not inspect rendered text. */
83
85
  placement(): PiShellStatusPlacement;
84
86
  renderDock(width: number): readonly string[];
@@ -44,12 +44,12 @@ export function createOwnedEditorClass(Base) {
44
44
  && this.getText().length === 0
45
45
  && !this.isShowingAutocomplete();
46
46
  }
47
- /** True when autocomplete is searching one top-level slash command that is the editor's only content. */
47
+ /** True when autocomplete is searching one space-free slash command that is the editor's only content. */
48
48
  isTopLevelCommandSearch() {
49
49
  if (!this.isShowingAutocomplete())
50
50
  return false;
51
51
  const text = this.getText();
52
- if (!/^\/[^\s/]*$/.test(text))
52
+ if (!/^\/\S*$/.test(text))
53
53
  return false;
54
54
  const cursor = this.getCursor();
55
55
  return cursor.line === 0 && cursor.col === text.length;
@@ -14,6 +14,7 @@ import { OWNED_UI_EXTENSION_CONTRACT_VERSION, OWNED_UI_EXTENSION_RENDER_CALLBACK
14
14
  import { PINNED_PI_SETTINGS_CALLBACKS, PINNED_PI_WORKFLOW_COMMAND_NAMES, } from "./workflows.js";
15
15
  import { createPiRuntimeIntegration } from "./runtime-integration.js";
16
16
  import { PiSessionCommandIntegration } from "./session-integration.js";
17
+ import { observeCompactionProgress } from "./compaction-progress.js";
17
18
  import { PiSettingsIntegration } from "./settings-integration.js";
18
19
  import { PendingEngineDelivery } from "./pending-delivery.js";
19
20
  /** Explicit flush failure when required delivery was interrupted rather than completed. */
@@ -147,6 +148,7 @@ export class PiEngineAdapter {
147
148
  #assistantResponseSequence = 0;
148
149
  #statusKind = null;
149
150
  #sessionCommands;
151
+ #compactionProgress = null;
150
152
  #gitBranch = null;
151
153
  #extensionUi;
152
154
  #extensionShutdown;
@@ -1059,6 +1061,7 @@ export class PiEngineAdapter {
1059
1061
  clearQueuedWorkflows() {
1060
1062
  const session = this.#requireWorkflowSession();
1061
1063
  const result = session.clearQueue?.();
1064
+ this.#sessionCommands?.forgetQueuedImages();
1062
1065
  if (!isRecord(result))
1063
1066
  return [];
1064
1067
  return [...readStringArray(result.steering), ...readStringArray(result.followUp)];
@@ -1257,6 +1260,8 @@ export class PiEngineAdapter {
1257
1260
  this.#emitEvent({ type: "session-lifecycle", lifecycle: "stopping", reason: null });
1258
1261
  this.#unsubscribe?.();
1259
1262
  this.#unsubscribe = undefined;
1263
+ this.#compactionProgress?.dispose();
1264
+ this.#compactionProgress = null;
1260
1265
  await this.#runtime?.dispose();
1261
1266
  this.#lifecycle = "stopped";
1262
1267
  this.#emitEvent({ type: "session-lifecycle", lifecycle: "stopped", reason: null });
@@ -2016,6 +2021,11 @@ export class PiEngineAdapter {
2016
2021
  this.#activeCommandIds = [];
2017
2022
  this.#completedCommands.clear();
2018
2023
  this.#sessionCommands = new PiSessionCommandIntegration(session);
2024
+ this.#compactionProgress?.dispose();
2025
+ this.#compactionProgress = observeCompactionProgress(session, percent => {
2026
+ if (this.#session === session && !this.#disposed)
2027
+ this.#publishCompactionProgress(percent);
2028
+ });
2019
2029
  this.#editor = {
2020
2030
  text: "",
2021
2031
  queuedSubmissions: [],
@@ -2024,7 +2034,7 @@ export class PiEngineAdapter {
2024
2034
  historyRevision: this.#editor.historyRevision + 1,
2025
2035
  submitEnabled: true,
2026
2036
  };
2027
- this.#status = { ...this.#status, workingMessage: null, badges: [] };
2037
+ this.#status = { ...this.#status, workingMessage: null, workingProgress: null, badges: [] };
2028
2038
  this.#statusKind = null;
2029
2039
  this.#agentRunActive = false;
2030
2040
  this.#agentRunSequence = 0;
@@ -2115,7 +2125,7 @@ export class PiEngineAdapter {
2115
2125
  const wasBusy = this.#lifecycle === "busy";
2116
2126
  this.#statusKind = kind;
2117
2127
  this.#lifecycle = "busy";
2118
- this.#status = { ...this.#status, workingMessage: message };
2128
+ this.#status = { ...this.#status, workingMessage: message, workingProgress: null };
2119
2129
  if (!wasBusy)
2120
2130
  this.#emitEvent({ type: "session-lifecycle", lifecycle: "busy", reason: null });
2121
2131
  this.#emitEvent({ type: "status", status: this.#status });
@@ -2133,10 +2143,32 @@ export class PiEngineAdapter {
2133
2143
  #leaveWorkStates() {
2134
2144
  this.#statusKind = null;
2135
2145
  this.#lifecycle = "ready";
2136
- this.#status = { ...this.#status, workingMessage: null };
2146
+ this.#status = { ...this.#status, workingMessage: null, workingProgress: null };
2137
2147
  this.#emitEvent({ type: "session-lifecycle", lifecycle: "ready", reason: null });
2138
2148
  this.#emitEvent({ type: "status", status: this.#status });
2139
2149
  }
2150
+ // Invariant: progress belongs to the compaction state only and is published when the integer changes.
2151
+ #publishCompactionProgress(percent) {
2152
+ if (this.#statusKind !== "compaction" || this.#status.workingProgress === percent)
2153
+ return;
2154
+ this.#status = { ...this.#status, workingProgress: percent };
2155
+ this.#emitEvent({ type: "status", status: this.#status });
2156
+ }
2157
+ // Rationale: a manual compaction ends with an idle session, so the engine would never consume
2158
+ // the messages queued during it; automatic compaction returns to a run or a pending prompt.
2159
+ #deliverQueuedAfterCompaction() {
2160
+ const commands = this.#sessionCommands;
2161
+ const generation = this.#sessionGeneration;
2162
+ if (commands === undefined)
2163
+ return;
2164
+ const report = (error) => {
2165
+ if (generation !== this.#sessionGeneration || this.#disposed)
2166
+ return;
2167
+ this.#addDiagnostic("error", "engine-command", `Queued input could not be sent after compaction: ${error instanceof Error ? error.message : String(error)}`, true);
2168
+ this.#emitView();
2169
+ };
2170
+ commands.deliverQueuedAfterCompaction(report).catch(report);
2171
+ }
2140
2172
  #handlePiEvent(event) {
2141
2173
  try {
2142
2174
  this.#applyPiEvent(event);
@@ -2286,9 +2318,13 @@ export class PiEngineAdapter {
2286
2318
  return;
2287
2319
  case "compaction_start":
2288
2320
  this.#enterWorkState("compaction", "Compacting");
2321
+ this.#compactionProgress?.begin();
2289
2322
  return;
2290
2323
  case "compaction_end":
2324
+ this.#compactionProgress?.end();
2291
2325
  this.#endWorkState("compaction");
2326
+ if (event.reason === "manual")
2327
+ this.#deliverQueuedAfterCompaction();
2292
2328
  return;
2293
2329
  case "thinking_level_changed":
2294
2330
  this.#thinkingLevel = readThinkingLevel(event.level);
@@ -2797,10 +2833,12 @@ export class PiEngineAdapter {
2797
2833
  this.#admissionStopped = !canResume;
2798
2834
  this.#unsubscribe?.();
2799
2835
  this.#unsubscribe = undefined;
2836
+ this.#compactionProgress?.dispose();
2837
+ this.#compactionProgress = null;
2800
2838
  ++this.#sessionGeneration;
2801
2839
  this.#agentRunActive = false;
2802
2840
  this.#statusKind = null;
2803
- this.#status = { ...this.#status, workingMessage: null };
2841
+ this.#status = { ...this.#status, workingMessage: null, workingProgress: null };
2804
2842
  this.#lifecycle = this.#disposed ? "stopped" : canResume ? "ready" : "failed";
2805
2843
  this.#editor = { ...this.#editor, submitEnabled: canResume && !this.#disposed };
2806
2844
  this.#viewRevision += 1;
@@ -0,0 +1,28 @@
1
+ /** Expected summary size for a first compaction, when no previous summary on the branch can serve as the estimate. */
2
+ export declare const DEFAULT_EXPECTED_COMPACTION_SUMMARY_CHARS = 4000;
3
+ /** The public session surface compaction progress reads: the agent's stream function and the branch entries. */
4
+ export interface CompactionProgressSession {
5
+ readonly agent?: {
6
+ streamFunction?: unknown;
7
+ } | undefined;
8
+ readonly sessionManager?: {
9
+ getBranch?(): readonly unknown[];
10
+ } | undefined;
11
+ }
12
+ export interface CompactionProgressObserver {
13
+ /** Marks `compaction_start`: resets the streamed count and captures the expected summary size. */
14
+ begin(): void;
15
+ /** Marks `compaction_end`: later stream chunks are ignored. */
16
+ end(): void;
17
+ /** Restores the original stream function; the observer reports nothing afterwards. */
18
+ dispose(): void;
19
+ }
20
+ /**
21
+ * Observes the summarization stream of a compaction through the session agent's public stream
22
+ * function and reports an estimated integer percent. Pi never iterates that stream itself (it
23
+ * reads only its final result), so the observer iterates it in the background between
24
+ * `begin()` and `end()` and returns the same stream object to Pi. Outside that window the
25
+ * wrapper is a pass-through. Returns null when the agent exposes no callable stream function,
26
+ * in which case compaction proceeds without progress.
27
+ */
28
+ export declare function observeCompactionProgress(session: CompactionProgressSession, onProgress: (percent: number) => void): CompactionProgressObserver | null;