@oh-my-pi/pi-coding-agent 15.11.0 → 15.11.1

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 (53) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/dist/cli.js +73 -67
  3. package/dist/types/capability/mcp.d.ts +1 -0
  4. package/dist/types/config/settings-schema.d.ts +13 -4
  5. package/dist/types/export/html/template.generated.d.ts +1 -1
  6. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  7. package/dist/types/mcp/oauth-flow.d.ts +6 -1
  8. package/dist/types/mcp/transports/stdio.d.ts +1 -0
  9. package/dist/types/mcp/types.d.ts +2 -0
  10. package/dist/types/modes/components/assistant-message.d.ts +1 -0
  11. package/dist/types/modes/components/mcp-add-wizard.d.ts +2 -1
  12. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  13. package/dist/types/modes/components/status-line/types.d.ts +3 -0
  14. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  15. package/dist/types/modes/controllers/tool-args-reveal.d.ts +43 -0
  16. package/dist/types/modes/theme/theme.d.ts +2 -1
  17. package/dist/types/task/index.d.ts +3 -3
  18. package/dist/types/tools/render-utils.d.ts +22 -0
  19. package/package.json +11 -11
  20. package/src/capability/mcp.ts +1 -0
  21. package/src/cli/gallery-cli.ts +5 -4
  22. package/src/config/mcp-schema.json +4 -0
  23. package/src/config/settings-schema.ts +15 -4
  24. package/src/edit/renderer.ts +96 -46
  25. package/src/export/html/template.generated.ts +1 -1
  26. package/src/export/html/template.js +6 -1
  27. package/src/internal-urls/docs-index.generated.ts +4 -4
  28. package/src/mcp/manager.ts +3 -0
  29. package/src/mcp/oauth-discovery.ts +27 -2
  30. package/src/mcp/oauth-flow.ts +47 -1
  31. package/src/mcp/transports/stdio.ts +3 -0
  32. package/src/mcp/types.ts +2 -0
  33. package/src/modes/components/assistant-message.ts +15 -0
  34. package/src/modes/components/btw-panel.ts +5 -1
  35. package/src/modes/components/mcp-add-wizard.ts +13 -0
  36. package/src/modes/components/settings-selector.ts +2 -0
  37. package/src/modes/components/status-line/component.ts +22 -12
  38. package/src/modes/components/status-line/types.ts +3 -0
  39. package/src/modes/components/transcript-container.ts +99 -18
  40. package/src/modes/components/tree-selector.ts +6 -1
  41. package/src/modes/controllers/event-controller.ts +28 -4
  42. package/src/modes/controllers/mcp-command-controller.ts +34 -2
  43. package/src/modes/controllers/selector-controller.ts +4 -0
  44. package/src/modes/controllers/tool-args-reveal.ts +174 -0
  45. package/src/modes/interactive-mode.ts +9 -2
  46. package/src/modes/theme/theme.ts +6 -0
  47. package/src/prompts/tools/task.md +7 -2
  48. package/src/session/agent-session.ts +25 -4
  49. package/src/task/index.ts +15 -10
  50. package/src/task/render.ts +10 -4
  51. package/src/tools/render-utils.ts +56 -0
  52. package/src/tools/write.ts +65 -47
  53. package/src/web/search/providers/anthropic.ts +29 -4
@@ -1,12 +1,18 @@
1
- import { type Component, Container, type NativeScrollbackLiveRegion, type RenderStablePrefix } from "@oh-my-pi/pi-tui";
1
+ import {
2
+ type Component,
3
+ Container,
4
+ type NativeScrollbackCommittedRows,
5
+ type NativeScrollbackLiveRegion,
6
+ type RenderStablePrefix,
7
+ } from "@oh-my-pi/pi-tui";
2
8
 
3
9
  const kSnapshot = Symbol("transcript.liveDiffSnapshot");
4
10
 
5
11
  /**
6
- * Per-block diff cache: the block's previous stripped contribution plus the
7
- * derived append-only state. Purely an input to {@link deriveLiveCommitState}
8
- * for still-live blocks it is never replayed as render output. Every block
9
- * renders its current content on every frame.
12
+ * Per-block render cache: the block's previous stripped contribution plus the
13
+ * derived append-only state. Still-live blocks use it as input to
14
+ * {@link deriveLiveCommitState}; finalized blocks wholly inside already
15
+ * committed native scrollback can replay it without calling render().
10
16
  */
11
17
  interface LiveDiffSnapshot {
12
18
  width: number;
@@ -47,6 +53,16 @@ interface SnapshotCarrier {
47
53
  */
48
54
  interface FinalizableBlock {
49
55
  isTranscriptBlockFinalized?(): boolean;
56
+ /**
57
+ * Monotonic content version for blocks that can still mutate *after*
58
+ * reporting finalized (e.g. `AssistantMessageComponent`: the inline error
59
+ * restored at the next turn's `agent_start`, late tool-result images). The
60
+ * committed-scrollback render bypass only replays a block's previous rows
61
+ * when the version is unchanged; without this signal a post-finalize
62
+ * mutation would stay invisible until a global invalidation. Blocks that
63
+ * never mutate post-finalize simply omit the method.
64
+ */
65
+ getTranscriptBlockVersion?(): number;
50
66
  }
51
67
 
52
68
  function isBlockFinalized(child: Component): boolean {
@@ -54,6 +70,11 @@ function isBlockFinalized(child: Component): boolean {
54
70
  return fn ? fn.call(child) : true;
55
71
  }
56
72
 
73
+ function getBlockVersion(child: Component): number | undefined {
74
+ const fn = (child as Component & FinalizableBlock).getTranscriptBlockVersion;
75
+ return fn ? fn.call(child) : undefined;
76
+ }
77
+
57
78
  // A "plain blank" row is empty or whitespace-only with no ANSI bytes. It marks
58
79
  // separation padding (a `Spacer`, or a no-background `paddingY` row) as opposed
59
80
  // to a background-colored padding row, whose escape sequences contain `\S` and
@@ -87,11 +108,16 @@ interface BlockSegment {
87
108
  rawRef: readonly string[];
88
109
  contribution: readonly string[];
89
110
  width: number;
111
+ generation: number;
90
112
  /** Frame row of this block's first emitted row (the separator when present). */
91
113
  startRow: number;
92
114
  /** Rows emitted: separator + contribution (0 for empty contributions). */
93
115
  rowCount: number;
94
116
  sep: number;
117
+ /** Whether the block reported finalized when this segment was rendered. */
118
+ finalized: boolean;
119
+ /** Block version observed when this segment was rendered (see {@link FinalizableBlock}). */
120
+ version: number | undefined;
95
121
  }
96
122
 
97
123
  const EMPTY_SEGMENTS: BlockSegment[] = [];
@@ -369,7 +395,10 @@ function deriveLiveCommitState(
369
395
  * through {@link RenderStablePrefix} so the engine can skip marker scanning,
370
396
  * line preparation, and the committed-prefix audit for those rows.
371
397
  */
372
- export class TranscriptContainer extends Container implements NativeScrollbackLiveRegion, RenderStablePrefix {
398
+ export class TranscriptContainer
399
+ extends Container
400
+ implements NativeScrollbackLiveRegion, NativeScrollbackCommittedRows, RenderStablePrefix
401
+ {
373
402
  // Bumped to retire every block's diff snapshot at once (theme change /
374
403
  // clear); a snapshot is only honored when its stored generation matches.
375
404
  #generation = 0;
@@ -390,6 +419,10 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
390
419
  #lines: string[] = [];
391
420
  #segments: BlockSegment[] = EMPTY_SEGMENTS;
392
421
  #renderWidth = -1;
422
+ // Local rows already committed to native scrollback by the previous frame.
423
+ // Finalized blocks wholly before this boundary are immutable on-screen history;
424
+ // their previous contribution can be replayed without calling render().
425
+ #committedRows = 0;
393
426
  // Stable-prefix floor accumulated across renders since the last
394
427
  // getRenderStablePrefixRows() read (see RenderStablePrefix: reading
395
428
  // consumes the report and re-bases the baseline). Out-of-band renders
@@ -407,6 +440,10 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
407
440
  super.clear();
408
441
  }
409
442
 
443
+ setNativeScrollbackCommittedRows(rows: number): void {
444
+ this.#committedRows = Number.isFinite(rows) ? Math.max(0, Math.trunc(rows)) : 0;
445
+ }
446
+
410
447
  getRenderStablePrefixRows(): number {
411
448
  const value = Math.min(this.#stableRowsFloor, this.#lines.length);
412
449
  this.#stableRowsFloor = this.#lines.length;
@@ -497,21 +534,43 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
497
534
 
498
535
  // This child's contribution: its current render with plain-blank
499
536
  // top/bottom edges stripped (the container owns inter-block gaps).
500
- // Always the latest content committed history keeps whatever bytes
501
- // it was written with, but the window must reflect the present state
502
- // (late tool results, post-finalize re-layouts, expand toggles).
503
- // A block whose render returned the same array reference reuses the
504
- // previously stripped contribution (same ref identical rows).
537
+ // Finalized blocks wholly inside committed native scrollback can reuse
538
+ // their previous contribution without calling render(): those rows are
539
+ // immutable terminal history for the current width/generation. Blocks
540
+ // outside committed history still render normally so late results,
541
+ // post-finalize re-layouts, and expand toggles remain visible.
505
542
  const previousSnapshot = child[kSnapshot];
506
- const raw = child.render(width);
507
543
  const previous = previousSegments[i];
508
- const reusable =
544
+ const finalized = isBlockFinalized(child);
545
+ const version = getBlockVersion(child);
546
+ const committedReusable =
509
547
  previous !== undefined &&
510
548
  previous.component === child &&
511
- previous.rawRef === raw &&
512
- previous.width === width;
549
+ previous.width === width &&
550
+ previous.generation === this.#generation &&
551
+ previous.startRow === row &&
552
+ previous.startRow + previous.rowCount <= this.#committedRows &&
553
+ finalized &&
554
+ // Only replay bytes that were themselves produced by a finalized
555
+ // render: a block finalizing between frames may have changed content
556
+ // while its rows were already committed via the append-only live
557
+ // path, so the first post-transition frame must render. Defense in
558
+ // depth on the transcript side — the TUI commit policy should keep
559
+ // that window closed, but the safety must not live there alone.
560
+ previous.finalized &&
561
+ // Post-finalize mutations (inline error restore, late tool images)
562
+ // bump the block version; a mismatch forces a real render so the
563
+ // committed-prefix audit can observe and re-anchor the change.
564
+ previous.version === version;
565
+ const raw = committedReusable ? previous.rawRef : child.render(width);
566
+ const reusable =
567
+ committedReusable ||
568
+ (previous !== undefined &&
569
+ previous.component === child &&
570
+ previous.rawRef === raw &&
571
+ previous.width === width &&
572
+ previous.generation === this.#generation);
513
573
  const contribution = reusable ? previous.contribution : stripPlainBlankEdges(raw);
514
- const finalized = isBlockFinalized(child);
515
574
  let liveCommitState: LiveCommitState | undefined;
516
575
  if (i >= liveStartIndex && !finalized) {
517
576
  liveCommitState = deriveLiveCommitState(previousSnapshot, contribution, width, this.#generation);
@@ -540,7 +599,18 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
540
599
  lines.length = row;
541
600
  }
542
601
  if (chainStable) stableRows = row;
543
- segments[i] = { component: child, rawRef: raw, contribution, width, startRow: row, rowCount: 0, sep: 0 };
602
+ segments[i] = {
603
+ component: child,
604
+ rawRef: raw,
605
+ contribution,
606
+ width,
607
+ generation: this.#generation,
608
+ startRow: row,
609
+ rowCount: 0,
610
+ sep: 0,
611
+ finalized,
612
+ version,
613
+ };
544
614
  continue;
545
615
  }
546
616
 
@@ -584,7 +654,18 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
584
654
  if (!(finalized && safeLength >= contribution.length)) commitSafeOpen = false;
585
655
  }
586
656
 
587
- segments[i] = { component: child, rawRef: raw, contribution, width, startRow: row, rowCount, sep };
657
+ segments[i] = {
658
+ component: child,
659
+ rawRef: raw,
660
+ contribution,
661
+ width,
662
+ generation: this.#generation,
663
+ startRow: row,
664
+ rowCount,
665
+ sep,
666
+ finalized,
667
+ version,
668
+ };
588
669
  row += rowCount;
589
670
  }
590
671
  // Trailing shrink: blocks removed from the tail leave stale rows behind
@@ -518,6 +518,7 @@ class TreeList implements Component {
518
518
  const renderedIndent = Math.min(displayIndent, maxIndentLevels);
519
519
  const scrollOffset = displayIndent - renderedIndent;
520
520
  const connectorPositionDisplay = hasConnector ? renderedIndent - 1 : -1;
521
+ const chainGutter = !hasConnector ? flatNode.gutters[flatNode.gutters.length - 1] : undefined;
521
522
 
522
523
  // Build prefix char by char, placing gutters and connector at their positions
523
524
  const totalChars = renderedIndent * 3;
@@ -530,8 +531,12 @@ class TreeList implements Component {
530
531
  // Check if there's a gutter at this level (translated to original tree depth)
531
532
  const gutter = flatNode.gutters.find(g => g.position === originalLevel);
532
533
  if (gutter) {
534
+ // Chain rows (no connector of their own) extend only their
535
+ // nearest connector gutter so the flattened conversation flow
536
+ // stays anchored without reviving unrelated `└─` ancestors (#2298).
537
+ const showVertical = gutter.show || gutter === chainGutter;
533
538
  if (posInLevel === 0) {
534
- prefixChars.push(gutter.show ? theme.tree.vertical : " ");
539
+ prefixChars.push(showVertical ? theme.tree.vertical : " ");
535
540
  } else {
536
541
  prefixChars.push(" ");
537
542
  }
@@ -21,6 +21,7 @@ import { isSilentAbort, readPendingDisplayTag, resolveAbortLabel } from "../../s
21
21
  import type { ResolveToolDetails } from "../../tools/resolve";
22
22
  import { interruptHint } from "../shared";
23
23
  import { StreamingRevealController } from "./streaming-reveal";
24
+ import { ToolArgsRevealController } from "./tool-args-reveal";
24
25
 
25
26
  type AgentSessionEventKind = AgentSessionEvent["type"];
26
27
 
@@ -86,6 +87,7 @@ export class EventController {
86
87
  // rules into this block while it is still the (live-region) transcript tail.
87
88
  #lastTtsrNotification: TtsrNotificationComponent | undefined = undefined;
88
89
  #streamingReveal: StreamingRevealController;
90
+ #toolArgsReveal: ToolArgsRevealController;
89
91
  #handlers: AgentSessionEventHandlers;
90
92
 
91
93
  constructor(private ctx: InteractiveModeContext) {
@@ -94,6 +96,10 @@ export class EventController {
94
96
  getHideThinkingBlock: () => this.ctx.hideThinkingBlock,
95
97
  requestRender: () => this.ctx.ui.requestRender(),
96
98
  });
99
+ this.#toolArgsReveal = new ToolArgsRevealController({
100
+ getSmoothStreaming: () => this.ctx.settings.get("display.smoothStreaming"),
101
+ requestRender: () => this.ctx.ui.requestRender(),
102
+ });
97
103
  this.#handlers = {
98
104
  agent_start: e => this.#handleAgentStart(e),
99
105
  agent_end: e => this.#handleAgentEnd(e),
@@ -127,6 +133,7 @@ export class EventController {
127
133
 
128
134
  dispose(): void {
129
135
  this.#streamingReveal.stop();
136
+ this.#toolArgsReveal.stop();
130
137
  this.#cancelIdleCompaction();
131
138
  for (const timer of this.#ircExpiryTimers.values()) {
132
139
  clearTimeout(timer);
@@ -492,10 +499,23 @@ export class EventController {
492
499
 
493
500
  // Preserve the raw partial JSON for renderers that need to surface fields before the JSON object closes.
494
501
  // Bash uses this to show inline env assignments during streaming instead of popping them in at completion.
495
- const renderArgs =
496
- "partialJson" in content
497
- ? { ...content.arguments, __partialJson: content.partialJson }
498
- : content.arguments;
502
+ // While the JSON is still open, ToolArgsRevealController paces the
503
+ // reveal (write/edit/bash previews grow smoothly when a slow provider
504
+ // delivers large batches); once it closes, the final args render
505
+ // as-is — mirroring how assistant text snaps at message_end.
506
+ const partialJson = "partialJson" in content ? content.partialJson : undefined;
507
+ let renderArgs: Record<string, unknown>;
508
+ if (typeof partialJson === "string") {
509
+ renderArgs = this.#toolArgsReveal.setTarget(
510
+ content.id,
511
+ partialJson,
512
+ content.customWireName !== undefined,
513
+ content.arguments,
514
+ );
515
+ } else {
516
+ this.#toolArgsReveal.finish(content.id);
517
+ renderArgs = content.arguments;
518
+ }
499
519
  if (!this.ctx.pendingTools.has(content.id)) {
500
520
  this.#resolveDisplaceablePoll(content.name);
501
521
  this.#resetReadGroup();
@@ -517,10 +537,12 @@ export class EventController {
517
537
  component.setExpanded(this.ctx.toolOutputExpanded);
518
538
  this.ctx.chatContainer.addChild(component);
519
539
  this.ctx.pendingTools.set(content.id, component);
540
+ this.#toolArgsReveal.bind(content.id, component);
520
541
  } else {
521
542
  const component = this.ctx.pendingTools.get(content.id);
522
543
  if (component) {
523
544
  component.updateArgs(renderArgs, content.id);
545
+ this.#toolArgsReveal.bind(content.id, component);
524
546
  }
525
547
  }
526
548
  }
@@ -555,6 +577,7 @@ export class EventController {
555
577
  if (this.ctx.streamingComponent && event.message.role === "assistant") {
556
578
  this.ctx.streamingMessage = event.message;
557
579
  this.#streamingReveal.stop();
580
+ this.#toolArgsReveal.flushAll();
558
581
  let errorMessage: string | undefined;
559
582
  const aborted = this.ctx.streamingMessage.stopReason === "aborted";
560
583
  const silentlyAborted = aborted && isSilentAbort(this.ctx.streamingMessage.errorMessage);
@@ -772,6 +795,7 @@ export class EventController {
772
795
  async #handleAgentEnd(_event: Extract<AgentSessionEvent, { type: "agent_end" }>): Promise<void> {
773
796
  this.#agentTurnActive = false;
774
797
  this.#streamingReveal.stop();
798
+ this.#toolArgsReveal.flushAll();
775
799
  if (this.ctx.loadingAnimation) {
776
800
  this.ctx.loadingAnimation.stop();
777
801
  this.ctx.loadingAnimation = undefined;
@@ -127,6 +127,7 @@ interface OAuthFlowResult {
127
127
  credentialId: string;
128
128
  clientId?: string;
129
129
  clientSecret?: string;
130
+ resource?: string;
130
131
  }
131
132
 
132
133
  type MCPAddScope = "user" | "project";
@@ -490,6 +491,7 @@ export class MCPCommandController {
490
491
 
491
492
  try {
492
493
  const oauthClientSecret = finalConfig.oauth?.clientSecret ?? "";
494
+ const oauthResource = oauth.resource ?? finalConfig.url;
493
495
  const oauthResult = await this.#handleOAuthFlow(
494
496
  oauth.authorizationUrl,
495
497
  oauth.tokenUrl,
@@ -499,15 +501,18 @@ export class MCPCommandController {
499
501
  finalConfig.oauth?.callbackPort,
500
502
  finalConfig.oauth?.callbackPath,
501
503
  finalConfig.oauth?.redirectUri,
504
+ oauthResource,
502
505
  );
503
506
  const persistedClientId = oauthResult.clientId ?? oauth.clientId ?? finalConfig.oauth?.clientId;
504
507
  const persistedClientSecret = oauthResult.clientSecret ?? finalConfig.oauth?.clientSecret;
508
+ const persistedResource = oauthResult.resource ?? oauthResource;
505
509
  finalConfig = {
506
510
  ...finalConfig,
507
511
  auth: {
508
512
  type: "oauth",
509
513
  credentialId: oauthResult.credentialId,
510
514
  tokenUrl: oauth.tokenUrl,
515
+ resource: persistedResource,
511
516
  clientId: persistedClientId,
512
517
  clientSecret: persistedClientSecret,
513
518
  },
@@ -548,8 +553,25 @@ export class MCPCommandController {
548
553
  done();
549
554
  this.#handleWizardCancel();
550
555
  },
551
- async (authUrl: string, tokenUrl: string, clientId: string, clientSecret: string, scopes: string) => {
552
- return await this.#handleOAuthFlow(authUrl, tokenUrl, clientId, clientSecret, scopes);
556
+ async (
557
+ authUrl: string,
558
+ tokenUrl: string,
559
+ clientId: string,
560
+ clientSecret: string,
561
+ scopes: string,
562
+ resource?: string,
563
+ ) => {
564
+ return await this.#handleOAuthFlow(
565
+ authUrl,
566
+ tokenUrl,
567
+ clientId,
568
+ clientSecret,
569
+ scopes,
570
+ undefined,
571
+ undefined,
572
+ undefined,
573
+ resource,
574
+ );
553
575
  },
554
576
  async (config: MCPServerConfig) => {
555
577
  return await this.#handleTestConnection(config);
@@ -579,6 +601,7 @@ export class MCPCommandController {
579
601
  callbackPort?: number,
580
602
  callbackPath?: string,
581
603
  redirectUri?: string,
604
+ resource?: string,
582
605
  ): Promise<OAuthFlowResult> {
583
606
  const authStorage = this.ctx.session.modelRegistry.authStorage;
584
607
  let parsedAuthUrl: URL;
@@ -617,6 +640,7 @@ export class MCPCommandController {
617
640
  redirectUri,
618
641
  callbackPort,
619
642
  callbackPath,
643
+ resource,
620
644
  },
621
645
  {
622
646
  onAuth: (info: { url: string; instructions?: string }) => {
@@ -704,6 +728,7 @@ export class MCPCommandController {
704
728
  credentialId,
705
729
  clientId: flow.resolvedClientId,
706
730
  clientSecret: flow.registeredClientSecret,
731
+ resource: flow.resource,
707
732
  };
708
733
  } catch (error) {
709
734
  const errorMsg = error instanceof Error ? error.message : String(error);
@@ -804,6 +829,7 @@ export class MCPCommandController {
804
829
  tokenUrl: string;
805
830
  clientId?: string;
806
831
  scopes?: string;
832
+ resource?: string;
807
833
  }> {
808
834
  // First test if server actually needs auth by connecting without OAuth
809
835
  let connectionSucceeded = false;
@@ -1415,6 +1441,9 @@ export class MCPCommandController {
1415
1441
 
1416
1442
  this.#showMessage(["", theme.fg("muted", `Reauthorizing "${name}"...`), ""].join("\n"));
1417
1443
 
1444
+ const oauthResource =
1445
+ oauth.resource ?? currentAuth?.resource ?? ("url" in baseConfig ? baseConfig.url : undefined);
1446
+
1418
1447
  const oauthResult = await this.#handleOAuthFlow(
1419
1448
  oauth.authorizationUrl,
1420
1449
  oauth.tokenUrl,
@@ -1424,10 +1453,12 @@ export class MCPCommandController {
1424
1453
  found.config.oauth?.callbackPort,
1425
1454
  found.config.oauth?.callbackPath,
1426
1455
  found.config.oauth?.redirectUri,
1456
+ oauthResource,
1427
1457
  );
1428
1458
 
1429
1459
  const persistedClientId = oauthResult.clientId ?? oauth.clientId ?? found.config.oauth?.clientId;
1430
1460
  const persistedClientSecret = oauthResult.clientSecret ?? (oauthClientSecret || undefined);
1461
+ const persistedResource = oauthResult.resource ?? oauthResource;
1431
1462
 
1432
1463
  const updated: MCPServerConfig = {
1433
1464
  ...baseConfig,
@@ -1435,6 +1466,7 @@ export class MCPCommandController {
1435
1466
  type: "oauth",
1436
1467
  credentialId: oauthResult.credentialId,
1437
1468
  tokenUrl: oauth.tokenUrl,
1469
+ resource: persistedResource,
1438
1470
  clientId: persistedClientId,
1439
1471
  clientSecret: persistedClientSecret,
1440
1472
  },
@@ -120,6 +120,7 @@ export class SelectorController {
120
120
  separator: settings.get("statusLine.separator"),
121
121
  showHookStatus: settings.get("statusLine.showHookStatus"),
122
122
  sessionAccent: settings.get("statusLine.sessionAccent"),
123
+ transparent: settings.get("statusLine.transparent"),
123
124
  ...previewSettings,
124
125
  });
125
126
  this.ctx.updateEditorTopBorder();
@@ -147,6 +148,7 @@ export class SelectorController {
147
148
  separator: settings.get("statusLine.separator"),
148
149
  showHookStatus: settings.get("statusLine.showHookStatus"),
149
150
  sessionAccent: settings.get("statusLine.sessionAccent"),
151
+ transparent: settings.get("statusLine.transparent"),
150
152
  });
151
153
  this.ctx.updateEditorTopBorder();
152
154
  this.ctx.ui.requestRender();
@@ -351,6 +353,7 @@ export class SelectorController {
351
353
  case "statusLineShowHooks":
352
354
  case "statusLine.showHookStatus":
353
355
  case "statusLine.sessionAccent":
356
+ case "statusLine.transparent":
354
357
  case "statusLineSegments":
355
358
  case "statusLineModelThinking":
356
359
  case "statusLinePathAbbreviate":
@@ -369,6 +372,7 @@ export class SelectorController {
369
372
  separator: settings.get("statusLine.separator"),
370
373
  showHookStatus: settings.get("statusLine.showHookStatus"),
371
374
  sessionAccent: settings.get("statusLine.sessionAccent"),
375
+ transparent: settings.get("statusLine.transparent"),
372
376
  segmentOptions: settings.get("statusLine.segmentOptions"),
373
377
  };
374
378
  this.ctx.statusLine.updateSettings(statusLineSettings);
@@ -0,0 +1,174 @@
1
+ import { parseStreamingJson } from "@oh-my-pi/pi-ai/utils/json-parse";
2
+ import { nextStep, STREAMING_REVEAL_FRAME_MS } from "./streaming-reveal";
3
+
4
+ /** Minimal component surface the reveal pushes frames into. */
5
+ type ToolArgsRevealComponent = {
6
+ updateArgs(args: unknown, toolCallId?: string): void;
7
+ };
8
+
9
+ type ToolArgsRevealControllerOptions = {
10
+ getSmoothStreaming(): boolean;
11
+ requestRender(): void;
12
+ };
13
+
14
+ type RevealEntry = {
15
+ component: ToolArgsRevealComponent | undefined;
16
+ /** Latest raw streamed argument text (JSON for function tools, raw text for custom tools). */
17
+ target: string;
18
+ /** Revealed UTF-16 code units of `target`. */
19
+ revealed: number;
20
+ /** Custom-tool raw input: display args are `{ input: prefix }`, never parsed as JSON. */
21
+ rawInput: boolean;
22
+ };
23
+
24
+ /** Clamp a slice end into `text`, never splitting a surrogate pair: a prefix
25
+ * ending on a high surrogate would feed a lone surrogate into the parsed
26
+ * preview args (providers decode UTF-8 incrementally, so the raw stream
27
+ * itself never contains one). */
28
+ function clampSliceEnd(text: string, end: number): number {
29
+ if (end <= 0) return 0;
30
+ if (end >= text.length) return text.length;
31
+ const code = text.charCodeAt(end - 1);
32
+ return code >= 0xd800 && code <= 0xdbff ? end + 1 : end;
33
+ }
34
+
35
+ /** Display args for a revealed raw-stream prefix. Function-tool prefixes are
36
+ * re-parsed with the same streaming-tolerant parser providers use, so every
37
+ * frame is a state the provider itself could have produced; custom tools
38
+ * mirror the provider's `{ input }` shape. `__partialJson` carries the
39
+ * matching raw prefix for renderers that read it directly (bash env preview,
40
+ * edit strategies). */
41
+ function buildDisplayArgs(prefix: string, rawInput: boolean): Record<string, unknown> {
42
+ const base: Record<string, unknown> = rawInput ? { input: prefix } : parseStreamingJson(prefix);
43
+ return { ...base, __partialJson: prefix };
44
+ }
45
+
46
+ /**
47
+ * Paces streamed tool-call arguments the same way StreamingRevealController
48
+ * paces assistant text: providers that deliver `partialJson` in large batches
49
+ * (or throttle their partial parses) would otherwise make write/edit/bash
50
+ * streaming previews jump in chunks. Each pending tool call reveals its raw
51
+ * argument stream at the shared 30fps cadence with the same adaptive
52
+ * catch-up step, re-parsing the revealed prefix per frame.
53
+ *
54
+ * Reveal units are UTF-16 code units of the raw stream, not graphemes —
55
+ * the prefix goes through a JSON parser rather than straight to the screen,
56
+ * so only surrogate-pair integrity matters (see {@link clampSliceEnd}).
57
+ */
58
+ export class ToolArgsRevealController {
59
+ readonly #getSmoothStreaming: () => boolean;
60
+ readonly #requestRender: () => void;
61
+ readonly #entries = new Map<string, RevealEntry>();
62
+ #timer: NodeJS.Timeout | undefined;
63
+
64
+ constructor(options: ToolArgsRevealControllerOptions) {
65
+ this.#getSmoothStreaming = options.getSmoothStreaming;
66
+ this.#requestRender = options.requestRender;
67
+ }
68
+
69
+ /**
70
+ * Record the latest streamed argument text for a tool call and return the
71
+ * args to render right now. With smoothing disabled the full target passes
72
+ * through in the caller's legacy shape (`{ ...args, __partialJson }`).
73
+ */
74
+ setTarget(
75
+ id: string,
76
+ partialJson: string,
77
+ rawInput: boolean,
78
+ fullArgs: Record<string, unknown>,
79
+ ): Record<string, unknown> {
80
+ if (!this.#getSmoothStreaming()) {
81
+ // Toggle may flip mid-call: drop any live entry so ticks stop.
82
+ this.#entries.delete(id);
83
+ return { ...fullArgs, __partialJson: partialJson };
84
+ }
85
+ let entry = this.#entries.get(id);
86
+ if (!entry) {
87
+ entry = { component: undefined, target: partialJson, revealed: 0, rawInput };
88
+ this.#entries.set(id, entry);
89
+ } else {
90
+ // Streams only append; a non-prefix target means a rewind — snap into range.
91
+ if (!partialJson.startsWith(entry.target)) {
92
+ entry.revealed = Math.min(entry.revealed, partialJson.length);
93
+ }
94
+ entry.target = partialJson;
95
+ }
96
+ entry.revealed = clampSliceEnd(entry.target, entry.revealed);
97
+ this.#syncTimer();
98
+ return buildDisplayArgs(entry.target.slice(0, entry.revealed), entry.rawInput);
99
+ }
100
+
101
+ /** Attach the component future ticks push frames into. */
102
+ bind(id: string, component: ToolArgsRevealComponent): void {
103
+ const entry = this.#entries.get(id);
104
+ if (entry) entry.component = component;
105
+ }
106
+
107
+ /** Final arguments arrived (the JSON closed): drop the reveal so the
108
+ * caller's final-args render wins immediately, mirroring how assistant
109
+ * text snaps to the full message at message_end. */
110
+ finish(id: string): void {
111
+ this.#entries.delete(id);
112
+ if (this.#entries.size === 0) this.#stopTimer();
113
+ }
114
+
115
+ /** Snap every live entry to its full received stream and clear. Used at
116
+ * message_end (abort/error mid-stream) so sealed components freeze showing
117
+ * everything that arrived rather than a mid-reveal prefix. */
118
+ flushAll(): void {
119
+ for (const [id, entry] of this.#entries) {
120
+ if (entry.component && entry.revealed < entry.target.length) {
121
+ entry.component.updateArgs(buildDisplayArgs(entry.target, entry.rawInput), id);
122
+ }
123
+ }
124
+ this.#entries.clear();
125
+ this.#stopTimer();
126
+ }
127
+
128
+ /** Clear without pushing (teardown). */
129
+ stop(): void {
130
+ this.#entries.clear();
131
+ this.#stopTimer();
132
+ }
133
+
134
+ #syncTimer(): void {
135
+ for (const entry of this.#entries.values()) {
136
+ if (entry.revealed < entry.target.length) {
137
+ this.#startTimer();
138
+ return;
139
+ }
140
+ }
141
+ this.#stopTimer();
142
+ }
143
+
144
+ #startTimer(): void {
145
+ if (this.#timer) return;
146
+ this.#timer = setInterval(() => {
147
+ this.#tick();
148
+ }, STREAMING_REVEAL_FRAME_MS);
149
+ this.#timer.unref?.();
150
+ }
151
+
152
+ #stopTimer(): void {
153
+ if (!this.#timer) return;
154
+ clearInterval(this.#timer);
155
+ this.#timer = undefined;
156
+ }
157
+
158
+ #tick(): void {
159
+ let advanced = false;
160
+ for (const [id, entry] of this.#entries) {
161
+ const backlog = entry.target.length - entry.revealed;
162
+ if (backlog <= 0 || !entry.component) continue;
163
+ entry.revealed = clampSliceEnd(entry.target, entry.revealed + nextStep(backlog));
164
+ entry.component.updateArgs(buildDisplayArgs(entry.target.slice(0, entry.revealed), entry.rawInput), id);
165
+ advanced = true;
166
+ }
167
+ if (advanced) {
168
+ this.#requestRender();
169
+ } else {
170
+ // Every entry caught up (or unbound); setTarget restarts on growth.
171
+ this.#stopTimer();
172
+ }
173
+ }
174
+ }
@@ -496,8 +496,12 @@ export class InteractiveMode implements InteractiveModeContext {
496
496
  }
497
497
 
498
498
  playWelcomeIntro(): void {
499
- this.#welcomeComponent?.playIntro(() => this.ui.requestRender());
499
+ const welcome = this.#welcomeComponent;
500
+ // Component-scoped: the intro only mutates the welcome box's own rows,
501
+ // so a resumed long transcript is not re-walked per animation frame.
502
+ welcome?.playIntro(() => this.ui.requestComponentRender(welcome));
500
503
  }
504
+
501
505
  async init(options: InteractiveModeInitOptions = {}): Promise<void> {
502
506
  if (this.isInitialized) return;
503
507
 
@@ -1050,6 +1054,7 @@ export class InteractiveMode implements InteractiveModeContext {
1050
1054
  separator: settings.get("statusLine.separator"),
1051
1055
  showHookStatus: settings.get("statusLine.showHookStatus"),
1052
1056
  sessionAccent: settings.get("statusLine.sessionAccent"),
1057
+ transparent: settings.get("statusLine.transparent"),
1053
1058
  segmentOptions: settings.get("statusLine.segmentOptions"),
1054
1059
  });
1055
1060
  }
@@ -3036,7 +3041,9 @@ export class InteractiveMode implements InteractiveModeContext {
3036
3041
  this.#voiceAnimationInterval = setInterval(() => {
3037
3042
  this.#voiceHue = (this.#voiceHue + 8) % 360;
3038
3043
  this.#updateMicIcon();
3039
- this.ui.requestRender();
3044
+ // Component-scoped: the hue sweep only recolors the editor's cursor
3045
+ // glyph, so the transcript subtree is reused per animation frame.
3046
+ this.ui.requestComponentRender(this.editor);
3040
3047
  }, 60);
3041
3048
  }
3042
3049