@timurproko/a1 0.1.8-dev.ade9b77 → 0.1.8-dev.c64be0e

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,10 +9,11 @@ npm install --global @timurproko/a1@latest
9
9
  ## Commands
10
10
 
11
11
  ```sh
12
- a1 # A1-owned UI and profile: ~/.a1/agent
13
- a1 version # show Installed, Release (latest), and Next versions
14
- a1 update # update to npm latest
15
- a1 update:next # update to npm next (or a1 update:<commit> for a specific preview)
12
+ a1 # A1-owned UI and profile: ~/.a1/agent
13
+ a1 version # show Installed, Release (latest), and Next versions
14
+ a1 update # update to npm latest
15
+ a1 update:next # update to npm next (or a1 update:<commit> for a specific preview)
16
+ a1 update --models # refresh A1's model catalogs
16
17
  ```
17
18
 
18
19
  Prerelease builds — what `a1 update:next` installs — add two development profiles
@@ -35,7 +36,6 @@ a1 pi remove npm:pi-mcp-adapter # remove it again (alias: a1 pi uninstall)
35
36
  a1 pi list # list packages installed for a1
36
37
  a1 pi update --extensions # update every installed package
37
38
  a1 pi update npm:pi-mcp-adapter # update one of them
38
- a1 update --models # refresh A1's model catalogs
39
39
  ```
40
40
 
41
41
  A running session loads a newly installed package after a restart. Pi's own
@@ -5,7 +5,7 @@
5
5
  "platform": "darwin",
6
6
  "architecture": "arm64",
7
7
  "capability": "unsupported",
8
- "builtAt": "2026-08-25T06:52:40.013Z",
8
+ "builtAt": "2026-08-25T08:31:32.388Z",
9
9
  "artifact": {
10
10
  "filename": "process-guardian",
11
11
  "sha256": "7524bf568992517f50ed53196c861c5edeba0d7275633bb4735ab45bd5963a28",
@@ -5,7 +5,7 @@
5
5
  "platform": "linux",
6
6
  "architecture": "x64",
7
7
  "capability": "supported",
8
- "builtAt": "2026-08-25T06:52:58.359Z",
8
+ "builtAt": "2026-08-25T08:31:49.348Z",
9
9
  "artifact": {
10
10
  "filename": "process-guardian",
11
11
  "sha256": "29e22fe29de2828982bc67ef418c4adcaab1490281477b7bea592ec6fe621bcd",
@@ -5,10 +5,10 @@
5
5
  "platform": "win32",
6
6
  "architecture": "x64",
7
7
  "capability": "supported",
8
- "builtAt": "2026-08-25T06:54:04.992Z",
8
+ "builtAt": "2026-08-25T08:32:11.007Z",
9
9
  "artifact": {
10
10
  "filename": "process-guardian.exe",
11
- "sha256": "b8b0c81f275bd95b0cf4a63dd19f734a845947e47feded1d85a7a3562cf072db",
11
+ "sha256": "b22997c85da32360cdb382f568e618757654c746cccb825b7c771d011cca9ba6",
12
12
  "size": 172544
13
13
  },
14
14
  "provenance": {
@@ -11,6 +11,12 @@ export interface PackageCommandRequest {
11
11
  readonly verb: PackageCommandVerb;
12
12
  readonly source: string | null;
13
13
  }
14
+ export interface PackageCommandStyle {
15
+ readonly dim: (message: string) => string;
16
+ readonly bold: (message: string) => string;
17
+ readonly green: (message: string) => string;
18
+ readonly red: (message: string) => string;
19
+ }
14
20
  export interface PackageCommandEnvironment {
15
21
  readonly createPort: (input: AgentPackagesPortInput) => AgentPackagesPort;
16
22
  readonly cwd?: string;
@@ -18,6 +24,8 @@ export interface PackageCommandEnvironment {
18
24
  readonly stdout?: (message: string) => void;
19
25
  readonly stderr?: (message: string) => void;
20
26
  readonly initializeProfile?: typeof initializeProductProfile;
27
+ /** Defaults to Chalk's terminal-aware styles; injectable for transcript tests. */
28
+ readonly style?: PackageCommandStyle;
21
29
  }
22
30
  export declare function runPackageCommand(request: PackageCommandRequest, environment: PackageCommandEnvironment): Promise<number>;
23
- export declare function renderPackageOutcome(outcome: AgentPackageOutcome, profileRoot: string): string;
31
+ export declare function renderPackageOutcome(outcome: AgentPackageOutcome, profileRoot: string, style?: PackageCommandStyle): string;
@@ -1,3 +1,4 @@
1
+ import chalk from "chalk";
1
2
  import { configurationRootForProfile, initializeProductProfile, resolveLaunchProfilePaths } from "../features/launch/index.js";
2
3
  import { PRODUCT_TEXT } from "../product-identity.js";
3
4
  export async function runPackageCommand(request, environment) {
@@ -5,6 +6,7 @@ export async function runPackageCommand(request, environment) {
5
6
  const stderr = environment.stderr ?? (message => process.stderr.write(message));
6
7
  const cwd = environment.cwd ?? process.cwd();
7
8
  const processEnvironment = environment.environment ?? process.env;
9
+ const style = environment.style ?? chalk;
8
10
  const paths = resolveLaunchProfilePaths({ environment: processEnvironment });
9
11
  const profileRoot = configurationRootForProfile("a1", paths);
10
12
  if (profileRoot === null)
@@ -19,10 +21,10 @@ export async function runPackageCommand(request, environment) {
19
21
  const port = environment.createPort({
20
22
  profileRoot,
21
23
  cwd,
22
- onProgress: progress => stdout(`${progress.message}\n`),
24
+ onProgress: progress => stdout(`${style.dim(progress.message)}\n`),
23
25
  });
24
26
  const outcome = await runVerb(port, request);
25
- const rendered = renderPackageOutcome(outcome, profileRoot);
27
+ const rendered = renderPackageOutcome(outcome, profileRoot, style);
26
28
  (outcome.status === "completed" ? stdout : stderr)(rendered);
27
29
  return outcome.status === "completed" ? 0 : 1;
28
30
  }
@@ -37,38 +39,40 @@ async function runVerb(port, request) {
37
39
  throw new Error(PRODUCT_TEXT.diagnostic(`requires a source for ${request.verb}`));
38
40
  return request.verb === "install" ? await port.install(request.source) : await port.remove(request.source);
39
41
  }
40
- export function renderPackageOutcome(outcome, profileRoot) {
41
- const name = PRODUCT_TEXT.displayName;
42
+ export function renderPackageOutcome(outcome, profileRoot, style = chalk) {
43
+ // Model refresh remains an A1 top-level command. The `a1 pi` compatibility
44
+ // transcript applies to package operations only.
45
+ if (outcome.operation === "refresh-models") {
46
+ if (outcome.status === "failed") {
47
+ return `${PRODUCT_TEXT.diagnostic(`could not ${describeOperation(outcome.operation)}: ${outcome.detail ?? "unknown failure"}`)}\n`;
48
+ }
49
+ return `${PRODUCT_TEXT.displayName} refreshed the model catalogs in ${profileRoot}.\n`;
50
+ }
42
51
  if (outcome.status === "failed") {
43
- return `${PRODUCT_TEXT.diagnostic(`could not ${describeOperation(outcome.operation)}: ${outcome.detail ?? "unknown failure"}`)}\n`;
52
+ return `${style.red(`Error: ${outcome.detail ?? "Unknown package command error"}`)}\n`;
44
53
  }
45
54
  if (outcome.status === "not-found") {
46
- return `${PRODUCT_TEXT.diagnostic(`found no package matching ${outcome.source ?? "that source"} in ${profileRoot}.`)}\n`;
55
+ return `${style.red(`No matching package found for ${outcome.source ?? "that source"}`)}\n`;
47
56
  }
48
57
  switch (outcome.operation) {
49
58
  case "install":
50
- return `${name} installed ${outcome.source} into ${profileRoot}.\n`
51
- + `Restart ${PRODUCT_TEXT.commandName} for a running session to load it.\n`;
59
+ return `${style.green(`Installed ${outcome.source}`)}\n`;
52
60
  case "remove":
53
- return `${name} removed ${outcome.source} from ${profileRoot}.\n`;
61
+ return `${style.green(`Removed ${outcome.source}`)}\n`;
54
62
  case "update":
55
- return outcome.source === null
56
- ? `${name} updated the packages in ${profileRoot}.\n`
57
- : `${name} updated ${outcome.source}.\n`;
58
- case "refresh-models":
59
- return `${name} refreshed the model catalogs in ${profileRoot}.\n`;
63
+ return `${style.green(outcome.source === null ? "Updated packages" : `Updated ${outcome.source}`)}\n`;
60
64
  case "list":
61
- return renderPackageList(outcome, profileRoot);
65
+ return renderPackageList(outcome, style);
62
66
  }
63
67
  }
64
- function renderPackageList(outcome, profileRoot) {
68
+ function renderPackageList(outcome, style) {
65
69
  if (outcome.packages.length === 0)
66
- return `${PRODUCT_TEXT.displayName} has no packages installed in ${profileRoot}.\n`;
67
- const lines = [`Packages installed for ${PRODUCT_TEXT.commandName} in ${profileRoot}:`];
70
+ return `${style.dim("No packages installed.")}\n`;
71
+ const lines = [style.bold("User packages:")];
68
72
  for (const entry of outcome.packages) {
69
- lines.push(` ${entry.source}${entry.filtered ? " (partly enabled)" : ""}`);
73
+ lines.push(` ${entry.source}${entry.filtered ? " (filtered)" : ""}`);
70
74
  if (entry.installedPath !== null)
71
- lines.push(` ${entry.installedPath}`);
75
+ lines.push(style.dim(` ${entry.installedPath}`));
72
76
  }
73
77
  return `${lines.join("\n")}\n`;
74
78
  }
@@ -3,7 +3,7 @@ export * from "./conformance.js";
3
3
  export * from "./shell-components.js";
4
4
  export * from "./theme.js";
5
5
  export * from "./upstream/theme/theme-controller.js";
6
- export { applyConfiguredPiTheme, getAvailablePiThemes } from "./upstream/theme/theme.js";
6
+ export { applyConfiguredPiTheme, getAvailablePiThemes, onPiThemeChange } from "./upstream/theme/theme.js";
7
7
  export * from "./upstream/components/countdown-timer.js";
8
8
  export * from "./upstream/components/extension-editor.js";
9
9
  export * from "./upstream/components/session-selector.js";
@@ -3,7 +3,7 @@ export * from "./conformance.js";
3
3
  export * from "./shell-components.js";
4
4
  export * from "./theme.js";
5
5
  export * from "./upstream/theme/theme-controller.js";
6
- export { applyConfiguredPiTheme, getAvailablePiThemes } from "./upstream/theme/theme.js";
6
+ export { applyConfiguredPiTheme, getAvailablePiThemes, onPiThemeChange } from "./upstream/theme/theme.js";
7
7
  export * from "./upstream/components/countdown-timer.js";
8
8
  export * from "./upstream/components/extension-editor.js";
9
9
  export * from "./upstream/components/session-selector.js";
@@ -60,11 +60,18 @@ export interface OwnedPiResourceSummary {
60
60
  readonly sourcePath: string | null;
61
61
  readonly diagnostic: string | null;
62
62
  }
63
+ export interface OwnedPiExtensionSourceSummary {
64
+ readonly source: string;
65
+ readonly scope: "user" | "project" | "temporary";
66
+ readonly origin: "package" | "top-level";
67
+ readonly baseDir: string | null;
68
+ }
63
69
  export interface OwnedPiExtensionResourceSummary {
64
70
  readonly kind: "extension";
65
71
  readonly id: string;
66
72
  readonly sourcePath: string | null;
67
73
  readonly resolvedPath: string | null;
74
+ readonly sourceInfo: OwnedPiExtensionSourceSummary | null;
68
75
  readonly loaded: boolean;
69
76
  readonly hidden: boolean;
70
77
  readonly diagnostic: string | null;
@@ -11,6 +11,12 @@ import { createPiRuntimeIntegration } from "./runtime-integration.js";
11
11
  import { PiSessionCommandIntegration } from "./session-integration.js";
12
12
  import { PiSettingsIntegration } from "./settings-integration.js";
13
13
  const execFileAsync = promisify(execFile);
14
+ /**
15
+ * Engine events delivered before the queue hands the event loop a turn. Small enough
16
+ * that a streaming burst never holds input, large enough that an ordinary turn is one
17
+ * batch.
18
+ */
19
+ const EVENT_DELIVERY_BATCH = 16;
14
20
  const DEFAULT_SURFACE = {
15
21
  columns: 100,
16
22
  rows: 32,
@@ -53,6 +59,8 @@ export class PiEngineAdapter {
53
59
  #activeCommandIds = [];
54
60
  #completedCommands = new Map();
55
61
  #transcript = [];
62
+ #transcriptIndex = new Map();
63
+ #transcriptSnapshot;
56
64
  #messageBlockIds = new WeakMap();
57
65
  #messageFallbackIds = new Map();
58
66
  #toolBlockIds = new Map();
@@ -61,6 +69,8 @@ export class PiEngineAdapter {
61
69
  #eventQueue = [];
62
70
  #eventQueueProcessing;
63
71
  #droppedEventCount = 0;
72
+ #agentRunActive = false;
73
+ #statusKind = null;
64
74
  #sessionCommands;
65
75
  #gitBranch = null;
66
76
  #extensionUi;
@@ -278,6 +288,7 @@ export class PiEngineAdapter {
278
288
  id: `extension-${resources.length}`,
279
289
  sourcePath: extension.path,
280
290
  resolvedPath: extension.resolvedPath,
291
+ sourceInfo: extensionSourceSummary(extension.sourceInfo),
281
292
  loaded: true,
282
293
  hidden: extension.hidden === true,
283
294
  diagnostic: null,
@@ -725,7 +736,7 @@ export class PiEngineAdapter {
725
736
  sessionId: this.#sessionId,
726
737
  revision: this.#viewRevision,
727
738
  lifecycle: this.#lifecycle,
728
- transcript: this.#transcript.map(block => ({ ...block })),
739
+ transcript: this.#transcriptSnapshot ??= Object.freeze([...this.#transcript]),
729
740
  editor: { ...this.#editor, queuedSubmissions: [...this.#editor.queuedSubmissions] },
730
741
  status: {
731
742
  ...this.#status,
@@ -1351,6 +1362,8 @@ export class PiEngineAdapter {
1351
1362
  submitEnabled: true,
1352
1363
  };
1353
1364
  this.#status = { ...this.#status, workingMessage: null, badges: [] };
1365
+ this.#statusKind = null;
1366
+ this.#agentRunActive = false;
1354
1367
  this.#activeModel = readModel(session.model);
1355
1368
  this.#reconcileActiveModelAvailability();
1356
1369
  this.#thinkingLevel = readThinkingLevel(session.thinkingLevel);
@@ -1403,28 +1416,76 @@ export class PiEngineAdapter {
1403
1416
  this.#emitView();
1404
1417
  }
1405
1418
  }
1419
+ /**
1420
+ * Replaces the transcript and the index that finds a block by its identifier. Every
1421
+ * lookup goes through the index, so streaming a chunk costs the same in a long session
1422
+ * as in a new one.
1423
+ */
1424
+ #setTranscript(blocks) {
1425
+ this.#transcript = blocks;
1426
+ this.#transcriptIndex.clear();
1427
+ for (const [index, block] of blocks.entries())
1428
+ this.#transcriptIndex.set(block.id, index);
1429
+ this.#transcriptSnapshot = undefined;
1430
+ }
1431
+ #transcriptBlock(id) {
1432
+ const index = this.#transcriptIndex.get(id);
1433
+ return index === undefined ? undefined : this.#transcript[index];
1434
+ }
1435
+ /** Shows the state named by `kind`, which becomes the state a later end can clear. */
1436
+ #enterWorkState(kind, message) {
1437
+ const wasBusy = this.#lifecycle === "busy";
1438
+ this.#statusKind = kind;
1439
+ this.#lifecycle = "busy";
1440
+ this.#status = { ...this.#status, workingMessage: message };
1441
+ if (!wasBusy)
1442
+ this.#emitEvent({ type: "session-lifecycle", lifecycle: "busy", reason: null });
1443
+ this.#emitEvent({ type: "status", status: this.#status });
1444
+ }
1445
+ /**
1446
+ * Ends one named state. A state the shell is not in is left alone, so a finished
1447
+ * compaction or retry cannot clear the working state it never replaced. While the run
1448
+ * continues, ending either of those returns to working rather than to idle.
1449
+ */
1450
+ #endWorkState(kind) {
1451
+ if (this.#statusKind !== kind)
1452
+ return;
1453
+ if (this.#agentRunActive) {
1454
+ this.#enterWorkState("working", "Working...");
1455
+ return;
1456
+ }
1457
+ this.#leaveWorkStates();
1458
+ }
1459
+ /** Leaves every work state and reports the session idle. */
1460
+ #leaveWorkStates() {
1461
+ this.#statusKind = null;
1462
+ this.#lifecycle = "ready";
1463
+ this.#status = { ...this.#status, workingMessage: null };
1464
+ this.#emitEvent({ type: "session-lifecycle", lifecycle: "ready", reason: null });
1465
+ this.#emitEvent({ type: "status", status: this.#status });
1466
+ }
1406
1467
  #handlePiEvent(event) {
1407
1468
  if (!isRecord(event) || typeof event.type !== "string")
1408
1469
  return;
1409
1470
  switch (event.type) {
1410
1471
  case "agent_start":
1411
- this.#lifecycle = "busy";
1412
- this.#status = { ...this.#status, workingMessage: "Working..." };
1413
- this.#emitEvent({ type: "session-lifecycle", lifecycle: "busy", reason: null });
1414
- this.#emitEvent({ type: "status", status: this.#status });
1472
+ this.#agentRunActive = true;
1473
+ this.#enterWorkState("working", "Working...");
1415
1474
  return;
1416
1475
  case "message_start":
1417
1476
  this.#upsertMessageBlock(event.message, "live");
1418
1477
  return;
1419
1478
  case "message_update": {
1420
- const block = this.#upsertMessageBlock(event.message, "live");
1421
- if (block && isRecord(event.assistantMessageEvent) && typeof event.assistantMessageEvent.delta === "string") {
1422
- this.#upsertTranscriptBlock({
1423
- ...block,
1424
- text: block.text.endsWith(event.assistantMessageEvent.delta)
1425
- ? block.text
1426
- : `${block.text}${event.assistantMessageEvent.delta}`,
1427
- });
1479
+ const delta = isRecord(event.assistantMessageEvent) && typeof event.assistantMessageEvent.delta === "string"
1480
+ ? event.assistantMessageEvent.delta
1481
+ : undefined;
1482
+ // The delta is folded in before the block is stored, so a chunk is one update to
1483
+ // one block rather than a store without the delta followed by a store with it.
1484
+ const blocks = this.#messageBlocks(event.message, "live", this.#transcript.length);
1485
+ for (const [index, block] of blocks.entries()) {
1486
+ this.#upsertTranscriptBlock(index === 0 && delta !== undefined && !block.text.endsWith(delta)
1487
+ ? { ...block, text: `${block.text}${delta}` }
1488
+ : block);
1428
1489
  }
1429
1490
  return;
1430
1491
  }
@@ -1453,11 +1514,18 @@ export class PiEngineAdapter {
1453
1514
  if (finalMessages.length > 0)
1454
1515
  this.#rebuildTranscript(finalMessages, "finalized");
1455
1516
  else
1456
- this.#transcript = this.#transcript.map(block => block.status === "live" ? { ...block, status: "finalized" } : block);
1457
- this.#lifecycle = "ready";
1458
- this.#status = { ...this.#status, workingMessage: null };
1459
- this.#emitEvent({ type: "session-lifecycle", lifecycle: "ready", reason: null });
1460
- this.#emitEvent({ type: "status", status: this.#status });
1517
+ this.#setTranscript(this.#transcript.map(block => block.status === "live" ? { ...block, status: "finalized" } : block));
1518
+ // Ending a turn leaves the working state, as the recorded pinned baseline does, but
1519
+ // it leaves only that state: a compaction or retry being shown outlives the turn
1520
+ // that ended under it. Settlement ends the run, and with it every state — the
1521
+ // engine ends a turn for each continuation it makes and settles once.
1522
+ if (event.type === "agent_settled") {
1523
+ this.#agentRunActive = false;
1524
+ this.#leaveWorkStates();
1525
+ }
1526
+ else if (this.#statusKind === null || this.#statusKind === "working") {
1527
+ this.#leaveWorkStates();
1528
+ }
1461
1529
  this.#emitView();
1462
1530
  return;
1463
1531
  }
@@ -1473,20 +1541,16 @@ export class PiEngineAdapter {
1473
1541
  return;
1474
1542
  }
1475
1543
  case "auto_retry_start":
1476
- this.#lifecycle = "busy";
1477
- this.#status = { ...this.#status, workingMessage: "Retrying…" };
1478
- this.#emitEvent({ type: "status", status: this.#status });
1544
+ this.#enterWorkState("retry", "Retrying…");
1479
1545
  return;
1480
1546
  case "auto_retry_end":
1481
- case "compaction_end":
1482
- this.#lifecycle = "ready";
1483
- this.#status = { ...this.#status, workingMessage: null };
1484
- this.#emitEvent({ type: "status", status: this.#status });
1547
+ this.#endWorkState("retry");
1485
1548
  return;
1486
1549
  case "compaction_start":
1487
- this.#lifecycle = "busy";
1488
- this.#status = { ...this.#status, workingMessage: "Compacting…" };
1489
- this.#emitEvent({ type: "status", status: this.#status });
1550
+ this.#enterWorkState("compaction", "Compacting…");
1551
+ return;
1552
+ case "compaction_end":
1553
+ this.#endWorkState("compaction");
1490
1554
  return;
1491
1555
  case "thinking_level_changed":
1492
1556
  this.#thinkingLevel = readThinkingLevel(event.level);
@@ -1576,7 +1640,13 @@ export class PiEngineAdapter {
1576
1640
  }
1577
1641
  }
1578
1642
  }
1579
- this.#transcript = blocks;
1643
+ // An authoritative rebuild restates most of what is already there. Reusing the block
1644
+ // that already says it keeps its revision, and with it the rows the shell rendered for
1645
+ // it — otherwise every turn that ends re-renders the whole session.
1646
+ this.#setTranscript(blocks.map(block => {
1647
+ const existing = this.#transcriptBlock(block.id);
1648
+ return existing !== undefined && sameBlockContent(existing, block) ? existing : block;
1649
+ }));
1580
1650
  }
1581
1651
  #upsertMessageBlock(message, status) {
1582
1652
  const blocks = this.#messageBlocks(message, status, this.#transcript.length);
@@ -1661,7 +1731,7 @@ export class PiEngineAdapter {
1661
1731
  : this.#toolBlockIds.get(toolCallId) ?? `tool-${toolCallId}`;
1662
1732
  if (toolCallId !== undefined)
1663
1733
  this.#toolBlockIds.set(toolCallId, blockId);
1664
- const existing = this.#transcript.find(block => block.id === blockId);
1734
+ const existing = this.#transcriptBlock(blockId);
1665
1735
  const existingPayload = isRecord(existing?.payload) ? existing.payload : undefined;
1666
1736
  return [{
1667
1737
  id: blockId,
@@ -1752,11 +1822,20 @@ export class PiEngineAdapter {
1752
1822
  });
1753
1823
  }
1754
1824
  #upsertTranscriptBlock(block) {
1755
- const index = this.#transcript.findIndex(existing => existing.id === block.id);
1756
- if (index >= 0)
1825
+ const index = this.#transcriptIndex.get(block.id);
1826
+ if (index !== undefined) {
1827
+ const existing = this.#transcript[index];
1828
+ // Nothing to tell the shell about a block that repeats itself, and keeping the
1829
+ // revision keeps the rows it already rendered.
1830
+ if (existing !== undefined && sameBlockContent(existing, block))
1831
+ return;
1757
1832
  this.#transcript[index] = block;
1758
- else
1833
+ }
1834
+ else {
1835
+ this.#transcriptIndex.set(block.id, this.#transcript.length);
1759
1836
  this.#transcript.push(block);
1837
+ }
1838
+ this.#transcriptSnapshot = undefined;
1760
1839
  this.#emitEvent({ type: "transcript-block", block });
1761
1840
  }
1762
1841
  #messageBlockId(message, fallbackIndex, status, occurrence) {
@@ -1774,7 +1853,7 @@ export class PiEngineAdapter {
1774
1853
  }
1775
1854
  }
1776
1855
  else {
1777
- id = [...cached].reverse().find(candidate => this.#transcript.some(block => block.id === candidate && block.status === "live"));
1856
+ id = [...cached].reverse().find(candidate => this.#transcriptBlock(candidate)?.status === "live");
1778
1857
  if (id === undefined && status === "finalized")
1779
1858
  id = cached.at(-1);
1780
1859
  if (id === undefined) {
@@ -1787,7 +1866,7 @@ export class PiEngineAdapter {
1787
1866
  return id;
1788
1867
  }
1789
1868
  #nextBlockRevision(id) {
1790
- const existing = this.#transcript.find(block => block.id === id);
1869
+ const existing = this.#transcriptBlock(id);
1791
1870
  if (existing)
1792
1871
  return existing.revision + 1;
1793
1872
  this.#nextBlockSequence += 1;
@@ -1848,6 +1927,7 @@ export class PiEngineAdapter {
1848
1927
  }
1849
1928
  async #processEventQueue() {
1850
1929
  try {
1930
+ let deliveredSinceYield = 0;
1851
1931
  while (this.#eventQueue.length > 0) {
1852
1932
  const event = this.#eventQueue.shift();
1853
1933
  if (!event)
@@ -1860,6 +1940,14 @@ export class PiEngineAdapter {
1860
1940
  this.#recordDiagnostic("warning", "event-listener", error instanceof Error ? error.message : String(error), true);
1861
1941
  }
1862
1942
  }
1943
+ deliveredSinceYield += 1;
1944
+ // A microtask chain runs to exhaustion before the loop turns, so a streaming
1945
+ // burst would hold typed input, pointer reports, and timed indicators until it
1946
+ // drained. Yielding on a macrotask hands those their turn between batches.
1947
+ if (deliveredSinceYield >= EVENT_DELIVERY_BATCH && this.#eventQueue.length > 0) {
1948
+ deliveredSinceYield = 0;
1949
+ await new Promise(resolve => { setImmediate(resolve); });
1950
+ }
1863
1951
  }
1864
1952
  }
1865
1953
  finally {
@@ -2246,11 +2334,26 @@ function extensionResourceDiagnostic(index, sourcePath, diagnostic) {
2246
2334
  id: `extension-diagnostic-${index}`,
2247
2335
  sourcePath,
2248
2336
  resolvedPath: null,
2337
+ sourceInfo: null,
2249
2338
  loaded: false,
2250
2339
  hidden: false,
2251
2340
  diagnostic,
2252
2341
  };
2253
2342
  }
2343
+ function extensionSourceSummary(value) {
2344
+ if (!isRecord(value))
2345
+ return null;
2346
+ const source = stringProperty(value, "source");
2347
+ const scope = value.scope;
2348
+ const origin = value.origin;
2349
+ const baseDir = value.baseDir;
2350
+ if (!source
2351
+ || (scope !== "user" && scope !== "project" && scope !== "temporary")
2352
+ || (origin !== "package" && origin !== "top-level")
2353
+ || (baseDir !== undefined && typeof baseDir !== "string"))
2354
+ return null;
2355
+ return { source, scope, origin, baseDir: baseDir ?? null };
2356
+ }
2254
2357
  function collectionResult(value, key) {
2255
2358
  if (!isRecord(value))
2256
2359
  return { values: [], diagnostics: [] };
@@ -2299,6 +2402,36 @@ function compactResourceLabel(path) {
2299
2402
  const segments = path.replaceAll("\\", "/").split("/").filter(Boolean);
2300
2403
  return segments.at(-1) ?? path;
2301
2404
  }
2405
+ /**
2406
+ * Whether two blocks say the same thing. A block that says what it already said is not a
2407
+ * new revision: the shell renders a block once per revision, so bumping one it did not
2408
+ * need re-renders it for nothing.
2409
+ */
2410
+ function sameBlockContent(left, right) {
2411
+ return left.kind === right.kind
2412
+ && left.status === right.status
2413
+ && left.title === right.title
2414
+ && left.text === right.text
2415
+ && sameValue(left.payload, right.payload);
2416
+ }
2417
+ function sameValue(left, right) {
2418
+ if (left === right)
2419
+ return true;
2420
+ if (typeof left !== typeof right || left === null || right === null)
2421
+ return false;
2422
+ if (Array.isArray(left) || Array.isArray(right)) {
2423
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length)
2424
+ return false;
2425
+ return left.every((value, index) => sameValue(value, right[index]));
2426
+ }
2427
+ if (typeof left !== "object")
2428
+ return false;
2429
+ const leftKeys = Object.keys(left);
2430
+ const rightRecord = right;
2431
+ if (leftKeys.length !== Object.keys(rightRecord).length)
2432
+ return false;
2433
+ return leftKeys.every(key => Object.hasOwn(rightRecord, key) && sameValue(left[key], rightRecord[key]));
2434
+ }
2302
2435
  function textFromContent(content) {
2303
2436
  if (typeof content === "string")
2304
2437
  return content;
@@ -39,6 +39,12 @@ export declare class OwnedUiSessionShellRoot implements PiTuiComponentPort {
39
39
  readonly onDequeue?: () => void;
40
40
  }, startup?: PiShellHeaderOptions, agentDir?: string, extensionRenderers?: PiShellExtensionRendererResolver);
41
41
  update(view: OwnedUiSessionViewModel): void;
42
+ /**
43
+ * Applies one block: its component is created or updated in place and the order grows
44
+ * only when the block is new. A streamed chunk costs one component update rather than a
45
+ * walk of the whole transcript.
46
+ */
47
+ applyTranscriptBlock(block: OwnedUiSessionViewModel["transcript"][number]): void;
42
48
  render(width: number): readonly string[];
43
49
  layoutRoot(): PiTuiLayoutNode;
44
50
  transcriptComponent(id: string): PiShellTranscriptComponentPort | undefined;
@@ -1,6 +1,6 @@
1
1
  import { MOUSE_TRACKING_OFF, MOUSE_TRACKING_ON, parseMouseInput } from "../ui-components/index.js";
2
2
  import { PINNED_PI_HIDDEN_COMMAND_NAMES, PINNED_PI_WORKFLOW_COMMAND_NAMES, } from "../pi-engine-adapter/index.js";
3
- import { createPiExtensionUiBridge, createPiQueuedInputStatus, createPiShellArmin, createPiShellAuthProviderSelector, createPiShellChangelog, createPiShellDaxnuts, createPiShellDialog, createPiShellEarendilAnnouncement, createPiShellEditor, createPiShellExtensionSelector, createPiShellFooter, createPiShellHeader, createPiShellHotkeys, createPiShellLoadedResources, createPiShellLoginDialog, createPiShellModelSelector, createPiShellOperationLoader, createPiShellReloadBox, createPiShellScopedModelsSelector, createPiShellSelector, createPiShellSessionInfo, createPiShellSessionSelector, createPiShellSettingsSelector, createPiShellStatus, createPiShellTranscriptComponent, createPiShellTreeSelector, createPiShellTrustSelector, createPiShellUserMessageSelector, piTheme, renderPiShellPackageUpdateNotice, renderPiShellStartupDiagnostic, renderPiShellStatusText, renderPiShellTranscriptBlock, } from "../pi-component-adapter/index.js";
3
+ import { createPiExtensionUiBridge, createPiQueuedInputStatus, createPiShellArmin, createPiShellAuthProviderSelector, createPiShellChangelog, createPiShellDaxnuts, createPiShellDialog, createPiShellEarendilAnnouncement, createPiShellEditor, createPiShellExtensionSelector, createPiShellFooter, createPiShellHeader, createPiShellHotkeys, createPiShellLoadedResources, createPiShellLoginDialog, createPiShellModelSelector, createPiShellOperationLoader, createPiShellReloadBox, createPiShellScopedModelsSelector, createPiShellSelector, createPiShellSessionInfo, createPiShellSessionSelector, createPiShellSettingsSelector, createPiShellStatus, createPiShellTranscriptComponent, createPiShellTreeSelector, createPiShellTrustSelector, createPiShellUserMessageSelector, onPiThemeChange, piTheme, renderPiShellPackageUpdateNotice, renderPiShellStartupDiagnostic, renderPiShellStatusText, renderPiShellTranscriptBlock, } from "../pi-component-adapter/index.js";
4
4
  import { PiTuiRuntimeAdapter, } from "../pi-tui-runtime-adapter/index.js";
5
5
  export class OwnedUiSessionShellRoot {
6
6
  editor;
@@ -8,6 +8,9 @@ export class OwnedUiSessionShellRoot {
8
8
  resources;
9
9
  #cwd;
10
10
  #transcript = new Map();
11
+ #blocksById = new Map();
12
+ #renderedRows = new Map();
13
+ #themeUnsubscribe;
11
14
  #transcriptOrder = [];
12
15
  #view;
13
16
  #status;
@@ -54,6 +57,9 @@ export class OwnedUiSessionShellRoot {
54
57
  this.editor.addToHistory(block.text);
55
58
  }
56
59
  this.#syncTranscript(view.transcript);
60
+ // Colours come from the active theme, so rendered rows outlive their revision only
61
+ // until the theme under them changes.
62
+ this.#themeUnsubscribe = onPiThemeChange(() => this.#renderedRows.clear());
57
63
  }
58
64
  update(view) {
59
65
  this.#view = view;
@@ -65,6 +71,25 @@ export class OwnedUiSessionShellRoot {
65
71
  this.editor.setThinkingLevel(view.thinkingLevel);
66
72
  this.invalidate();
67
73
  }
74
+ /**
75
+ * Applies one block: its component is created or updated in place and the order grows
76
+ * only when the block is new. A streamed chunk costs one component update rather than a
77
+ * walk of the whole transcript.
78
+ */
79
+ applyTranscriptBlock(block) {
80
+ this.#blocksById.set(block.id, block);
81
+ const component = this.#transcript.get(block.id);
82
+ if (component === undefined) {
83
+ const created = createPiShellTranscriptComponent(block, this.#cwd, this.#extensionRenderers);
84
+ created.setExpanded(this.#toolsExpanded);
85
+ this.#transcript.set(block.id, created);
86
+ this.#transcriptOrder.push(block.id);
87
+ }
88
+ else if (component.revision !== block.revision) {
89
+ component.update(block);
90
+ }
91
+ this.invalidate();
92
+ }
68
93
  render(width) {
69
94
  const queued = this.#view.editor.queuedSubmissions.length === 0 ? [] : this.#queued.render(width);
70
95
  return [
@@ -128,10 +153,10 @@ export class OwnedUiSessionShellRoot {
128
153
  }
129
154
  #renderDocument(width) {
130
155
  const transcript = this.#transcriptOrder.flatMap((id, index) => {
131
- const block = this.#view.transcript.find(item => item.id === id);
156
+ const block = this.#blocksById.get(id);
132
157
  if (!this.#thinkingVisible && block?.kind === "thinking")
133
158
  return [];
134
- const rows = this.#transcript.get(id)?.render(width) ?? [];
159
+ const rows = this.#blockRows(id, block, width);
135
160
  if (index > 0 && block?.kind === "user")
136
161
  return ["", ...rows];
137
162
  return rows;
@@ -167,6 +192,25 @@ export class OwnedUiSessionShellRoot {
167
192
  ...diagnosticRows,
168
193
  ];
169
194
  }
195
+ /**
196
+ * Rows for one transcript block. A finalized block renders once for a given revision
197
+ * and width and is reused after that, so a frame costs what changed rather than what
198
+ * the session has accumulated. A live block, and anything the shell drives itself, is
199
+ * rendered every time because its content is still moving.
200
+ */
201
+ #blockRows(id, block, width) {
202
+ const component = this.#transcript.get(id);
203
+ if (component === undefined)
204
+ return [];
205
+ if (block === undefined || block.status !== "finalized")
206
+ return component.render(width);
207
+ const cached = this.#renderedRows.get(id);
208
+ if (cached && cached.width === width && cached.revision === block.revision)
209
+ return cached.rows;
210
+ const rows = component.render(width);
211
+ this.#renderedRows.set(id, { width, revision: block.revision, rows });
212
+ return rows;
213
+ }
170
214
  transcriptComponent(id) {
171
215
  return this.#transcript.get(id);
172
216
  }
@@ -334,6 +378,8 @@ export class OwnedUiSessionShellRoot {
334
378
  this.#extensionWorkingMessage = undefined;
335
379
  this.#status.setWorkingOverride(undefined);
336
380
  this.#footer.update(this.#viewWithExtensionStatuses(this.#view));
381
+ // An extension renderer may have drawn transcript blocks that are now unrendered by it.
382
+ this.#renderedRows.clear();
337
383
  this.invalidate();
338
384
  }
339
385
  addExtensionNotification(message, type) {
@@ -384,11 +430,13 @@ export class OwnedUiSessionShellRoot {
384
430
  this.#inputSurface.setFocused?.(focused);
385
431
  }
386
432
  dispose() {
433
+ this.#themeUnsubscribe();
387
434
  this.header.dispose?.();
388
435
  this.resources.dispose?.();
389
436
  for (const component of this.#transcript.values())
390
437
  component.dispose?.();
391
438
  this.#transcript.clear();
439
+ this.#renderedRows.clear();
392
440
  if (this.#inputSurface !== this.editor)
393
441
  this.#inputSurface.dispose?.();
394
442
  this.#extensionHeader?.dispose?.();
@@ -402,12 +450,16 @@ export class OwnedUiSessionShellRoot {
402
450
  this.#queued.dispose?.();
403
451
  }
404
452
  #syncTranscript(blocks) {
453
+ this.#blocksById.clear();
454
+ for (const block of blocks)
455
+ this.#blocksById.set(block.id, block);
405
456
  const nextIds = new Set(blocks.map(block => block.id));
406
457
  for (const [id, component] of this.#transcript) {
407
458
  if (id.startsWith("workflow-status-") || nextIds.has(id))
408
459
  continue;
409
460
  component.dispose?.();
410
461
  this.#transcript.delete(id);
462
+ this.#renderedRows.delete(id);
411
463
  }
412
464
  for (const block of blocks) {
413
465
  const component = this.#transcript.get(block.id);
@@ -431,8 +483,9 @@ export class OwnedUiSessionShellRoot {
431
483
  if (block !== undefined)
432
484
  order.push(block.id);
433
485
  }
486
+ const placed = new Set(order);
434
487
  for (const statusId of statusIds) {
435
- if (!order.includes(statusId))
488
+ if (!placed.has(statusId))
436
489
  order.push(statusId);
437
490
  }
438
491
  this.#transcriptOrder = order;
@@ -498,6 +551,8 @@ export class OwnedUiSessionShellRoot {
498
551
  this.resources.setExpanded(expanded);
499
552
  for (const component of this.#transcript.values())
500
553
  component.setExpanded(expanded);
554
+ // Expansion changes what a block draws without changing its revision.
555
+ this.#renderedRows.clear();
501
556
  this.invalidate();
502
557
  }
503
558
  }
@@ -525,12 +580,14 @@ function shellResourceEntries(backend) {
525
580
  sourcePath: resource.sourcePath,
526
581
  diagnostic: resource.diagnostic,
527
582
  }));
528
- for (const extension of backend.extensionResources()) {
529
- if (extension.hidden)
530
- continue;
583
+ const extensions = backend.extensionResources().filter(extension => !extension.hidden);
584
+ const loadedExtensions = extensions.filter(extension => extension.diagnostic === null);
585
+ const extensionLabels = compactExtensionLabels(loadedExtensions);
586
+ for (const extension of extensions) {
587
+ const labelIndex = loadedExtensions.indexOf(extension);
531
588
  resources.push({
532
589
  section: "Extensions",
533
- label: compactResourceLabel(extension.sourcePath ?? extension.resolvedPath ?? "extension"),
590
+ label: extensionLabels[labelIndex] ?? compactResourceLabel(extension.sourcePath ?? extension.resolvedPath ?? "extension"),
534
591
  sourcePath: extension.sourcePath ?? extension.resolvedPath,
535
592
  diagnostic: extension.diagnostic,
536
593
  });
@@ -538,12 +595,129 @@ function shellResourceEntries(backend) {
538
595
  return resources;
539
596
  }
540
597
  function compactResourceLabel(path) {
541
- const segments = path.replaceAll("\\", "/").split("/").filter(Boolean);
598
+ const segments = compactPathSegments(path);
542
599
  const leaf = segments.at(-1) ?? path;
543
600
  if ((leaf === "index.ts" || leaf === "index.js") && segments.length > 1)
544
601
  return segments.at(-2) ?? leaf;
545
602
  return leaf;
546
603
  }
604
+ /**
605
+ * Pinned from InteractiveMode's compact extension-label helpers at Pi commit
606
+ * 914cf1472e715297caa30db4b9535d534a9eb718. The source metadata crosses an
607
+ * A1-owned boundary first; the owned shell never inspects Pi's private root.
608
+ */
609
+ function compactExtensionLabels(extensions) {
610
+ const localExtensions = extensions
611
+ .filter(extension => !isPackageExtensionSource(extension.sourceInfo))
612
+ .map(extension => {
613
+ const path = extension.sourcePath ?? extension.resolvedPath ?? "extension";
614
+ const segments = compactPathSegments(path);
615
+ if (segments.length > 1 && (segments.at(-1) === "index.ts" || segments.at(-1) === "index.js"))
616
+ segments.pop();
617
+ return { extension, segments };
618
+ });
619
+ return extensions.map(extension => {
620
+ const resourcePath = extension.sourcePath ?? extension.resolvedPath ?? "extension";
621
+ if (isPackageExtensionSource(extension.sourceInfo)) {
622
+ return compactPackageExtensionLabel(resourcePath, extension.sourceInfo);
623
+ }
624
+ const localIndex = localExtensions.findIndex(item => item.extension === extension);
625
+ const segments = localExtensions[localIndex]?.segments;
626
+ if (!segments || segments.length === 0)
627
+ return compactResourceLabel(resourcePath);
628
+ for (let count = 1; count <= segments.length; count += 1) {
629
+ const candidate = segments.slice(-count).join("/");
630
+ if (localExtensions.every((item, itemIndex) => itemIndex === localIndex || item.segments.slice(-count).join("/") !== candidate)) {
631
+ return candidate;
632
+ }
633
+ }
634
+ return segments.join("/");
635
+ });
636
+ }
637
+ function compactPackageExtensionLabel(resourcePath, sourceInfo) {
638
+ const sourceLabel = compactPackageSourceLabel(sourceInfo.source);
639
+ if (!sourceLabel)
640
+ return compactResourceLabel(resourcePath);
641
+ const shortPath = shortPackagePath(resourcePath, sourceInfo).replaceAll("\\", "/");
642
+ const packagePath = shortPath.startsWith("extensions/") ? shortPath.slice("extensions/".length) : shortPath;
643
+ const slash = packagePath.lastIndexOf("/");
644
+ const fileName = slash < 0 ? packagePath : packagePath.slice(slash + 1);
645
+ const directory = slash < 0 ? "" : packagePath.slice(0, slash);
646
+ const extension = fileName.lastIndexOf(".");
647
+ const name = extension <= 0 ? fileName : fileName.slice(0, extension);
648
+ if (name === "index")
649
+ return !directory || directory === "." ? sourceLabel : `${sourceLabel}:${directory}`;
650
+ return `${sourceLabel}:${packagePath}`;
651
+ }
652
+ function compactPackageSourceLabel(source) {
653
+ if (source.startsWith("npm:"))
654
+ return source.slice("npm:".length) || source;
655
+ if (!source.startsWith("git:"))
656
+ return source;
657
+ const gitSource = source.slice("git:".length).trim();
658
+ let repositoryPath;
659
+ const scpLike = gitSource.match(/^git@[^:]+:(.+)$/);
660
+ if (scpLike?.[1]) {
661
+ repositoryPath = scpLike[1];
662
+ }
663
+ else if (/^[a-z]+:\/\//i.test(gitSource)) {
664
+ try {
665
+ repositoryPath = new URL(gitSource).pathname.replace(/^\/+/, "");
666
+ }
667
+ catch {
668
+ return source;
669
+ }
670
+ }
671
+ else {
672
+ const slash = gitSource.indexOf("/");
673
+ if (slash >= 0)
674
+ repositoryPath = gitSource.slice(slash + 1);
675
+ }
676
+ if (!repositoryPath)
677
+ return source;
678
+ const ref = repositoryPath.indexOf("@");
679
+ const withoutRef = ref < 0 ? repositoryPath : repositoryPath.slice(0, ref);
680
+ return withoutRef.replace(/\.git$/, "") || source;
681
+ }
682
+ function shortPackagePath(resourcePath, sourceInfo) {
683
+ const fullPath = normalizeResourcePath(resourcePath);
684
+ const baseDir = sourceInfo.baseDir === null ? undefined : normalizeResourcePath(sourceInfo.baseDir).replace(/\/$/, "");
685
+ if (baseDir) {
686
+ const npmRoot = baseDir.match(/^(.*\/node_modules)\/(@?[^/]+(?:\/[^/]+)?)$/);
687
+ if (npmRoot?.[1] && fullPath.startsWith(`${npmRoot[1]}/`))
688
+ return relativeResourcePath(baseDir, fullPath);
689
+ if (fullPath === baseDir)
690
+ return ".";
691
+ if (fullPath.startsWith(`${baseDir}/`))
692
+ return fullPath.slice(baseDir.length + 1);
693
+ }
694
+ const npmMatch = fullPath.match(/node_modules\/(@?[^/]+(?:\/[^/]+)?)\/(.*)/);
695
+ if (npmMatch?.[2] && sourceInfo.source.startsWith("npm:"))
696
+ return npmMatch[2];
697
+ const gitMatch = fullPath.match(/git\/[^/]+\/[^/]+\/(.*)/);
698
+ if (gitMatch?.[1] && sourceInfo.source.startsWith("git:"))
699
+ return gitMatch[1];
700
+ return resourcePath;
701
+ }
702
+ function relativeResourcePath(from, to) {
703
+ const fromSegments = from.split("/").filter(Boolean);
704
+ const toSegments = to.split("/").filter(Boolean);
705
+ let common = 0;
706
+ while (common < fromSegments.length && common < toSegments.length
707
+ && fromSegments[common]?.toLowerCase() === toSegments[common]?.toLowerCase())
708
+ common += 1;
709
+ return [...fromSegments.slice(common).map(() => ".."), ...toSegments.slice(common)].join("/") || ".";
710
+ }
711
+ function compactPathSegments(path) {
712
+ return normalizeResourcePath(path).split("/").filter(segment => segment.length > 0 && segment !== "~");
713
+ }
714
+ function normalizeResourcePath(path) {
715
+ return path.replaceAll("\\", "/");
716
+ }
717
+ function isPackageExtensionSource(sourceInfo) {
718
+ const source = sourceInfo?.source ?? "";
719
+ return source.startsWith("npm:") || source.startsWith("git:");
720
+ }
547
721
  export class OwnedUiSessionShell {
548
722
  backend;
549
723
  root;
@@ -560,6 +734,7 @@ export class OwnedUiSessionShell {
560
734
  #sequence = 0;
561
735
  #started = false;
562
736
  #disposed = false;
737
+ #pointerReporting = false;
563
738
  #compactionQueue = [];
564
739
  #lastClearTime = 0;
565
740
  #activeLoginDialog;
@@ -638,8 +813,13 @@ export class OwnedUiSessionShell {
638
813
  });
639
814
  this.root.editor.setAutocompleteCommands(this.backend.workflowAutocompleteCommands());
640
815
  this.#unsubscribe = this.backend.onEvent(event => {
641
- this.#syncView();
642
- if (this.view().lifecycle === "ready" && this.#compactionQueue.length > 0)
816
+ // A streamed chunk names one block, and touching only that block is what keeps the
817
+ // cost of a chunk the same in a long session as in a new one. Everything else
818
+ // resynchronizes the view, which is cheap next to re-reading the transcript.
819
+ const view = event.type === "transcript-block" && this.#sessionGeneration === this.backend.sessionGeneration
820
+ ? this.#syncBlock(event.block)
821
+ : this.#syncView();
822
+ if (view.lifecycle === "ready" && this.#compactionQueue.length > 0)
643
823
  void this.#flushCompactionQueue();
644
824
  if (event.type === "session-lifecycle" && event.lifecycle === "stopped")
645
825
  this.#resolveStopped?.();
@@ -1232,22 +1412,51 @@ export class OwnedUiSessionShell {
1232
1412
  this.root.appendDaxnuts();
1233
1413
  }
1234
1414
  }
1415
+ /**
1416
+ * Turns terminal pointer reporting on for a screen that reads the pointer, and off for
1417
+ * every path that ends it. While it is on the terminal hands A1 the wheel and the
1418
+ * button instead of scrolling and selecting itself, so leaving it on outlives the
1419
+ * screen that wanted it and takes the terminal's own scrolling and selection with it.
1420
+ */
1421
+ #setPointerReporting(enabled) {
1422
+ if (this.#pointerReporting === enabled)
1423
+ return;
1424
+ this.#pointerReporting = enabled;
1425
+ if (!this.runtime.active)
1426
+ return;
1427
+ this.runtime.writeControl(enabled ? MOUSE_TRACKING_ON : MOUSE_TRACKING_OFF);
1428
+ }
1235
1429
  async dispose() {
1236
1430
  if (this.#disposed)
1237
1431
  return;
1238
1432
  this.#disposed = true;
1433
+ this.#setPointerReporting(false);
1239
1434
  this.#unsubscribe();
1240
1435
  this.#dialogHandle?.hide();
1241
1436
  await this.backend.unbindExtensionUi();
1242
1437
  this.#extensionBridge.dispose();
1243
1438
  await this.runtime.dispose();
1244
1439
  }
1440
+ /**
1441
+ * Applies one transcript block without re-reading the session. The listeners still hear
1442
+ * the view they would have heard, so nothing downstream can tell the difference.
1443
+ */
1444
+ #syncBlock(block) {
1445
+ this.root.applyTranscriptBlock(block);
1446
+ this.runtime.requestRender();
1447
+ const view = this.view();
1448
+ for (const listener of this.#listeners)
1449
+ listener(view);
1450
+ return view;
1451
+ }
1245
1452
  #syncView() {
1246
1453
  const view = this.view();
1247
1454
  if (this.backend.sessionGeneration !== this.#sessionGeneration) {
1248
1455
  this.#sessionGeneration = this.backend.sessionGeneration;
1249
1456
  this.#activeLoginDialog = undefined;
1250
1457
  this.#extensionBridge.reset();
1458
+ // A replaced session takes its screens with it, pointer reporting included.
1459
+ this.#setPointerReporting(false);
1251
1460
  this.root.setInputSurface(null);
1252
1461
  this.root.resetExtensionUi();
1253
1462
  this.root.resetWorkflowPresentation();
@@ -1257,6 +1466,7 @@ export class OwnedUiSessionShell {
1257
1466
  this.runtime.requestRender();
1258
1467
  for (const listener of this.#listeners)
1259
1468
  listener(view);
1469
+ return view;
1260
1470
  }
1261
1471
  #openOwnedRoute(route) {
1262
1472
  const surface = this.#routeHost?.open(route) ?? null;
@@ -1267,7 +1477,7 @@ export class OwnedUiSessionShell {
1267
1477
  this.#dialogHandle?.hide();
1268
1478
  // Any-event reporting: hover and drag are what the screen is driven by, and
1269
1479
  // it also stops the terminal treating a drag as a text selection.
1270
- this.runtime.writeControl(MOUSE_TRACKING_ON);
1480
+ this.#setPointerReporting(true);
1271
1481
  // The interrupt chord is global, so it is watched on raw input rather than
1272
1482
  // through the overlay: the pinned shell handles that key before an overlay
1273
1483
  // ever sees it, which is why an owned screen must not rely on being asked.
@@ -1290,7 +1500,7 @@ export class OwnedUiSessionShell {
1290
1500
  });
1291
1501
  const closeSurface = () => {
1292
1502
  removeInterruptWatch();
1293
- this.runtime.writeControl(MOUSE_TRACKING_OFF);
1503
+ this.#setPointerReporting(false);
1294
1504
  this.#dialogHandle?.hide();
1295
1505
  this.#dialogHandle = undefined;
1296
1506
  this.#dialogId = undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timurproko/a1",
3
- "version": "0.1.8-dev.ade9b77",
3
+ "version": "0.1.8-dev.c64be0e",
4
4
  "description": "Standalone terminal workspace for supervised native and managed agents",
5
5
  "type": "module",
6
6
  "packageManager": "npm@11.13.0",
@@ -57,6 +57,7 @@
57
57
  "dependencies": {
58
58
  "@earendil-works/pi-coding-agent": "0.84.2",
59
59
  "@earendil-works/pi-tui": "0.84.2",
60
+ "chalk": "5.6.2",
60
61
  "cross-spawn": "7.0.6",
61
62
  "grok-mermaid": "0.2.2",
62
63
  "semver": "7.8.5"