@oh-my-pi/pi-coding-agent 15.10.0 → 15.10.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 (176) hide show
  1. package/CHANGELOG.md +75 -1
  2. package/dist/types/cli/dry-balance-cli.d.ts +15 -1
  3. package/dist/types/commit/analysis/conventional.d.ts +2 -2
  4. package/dist/types/commit/analysis/summary.d.ts +2 -2
  5. package/dist/types/commit/changelog/generate.d.ts +2 -2
  6. package/dist/types/commit/changelog/index.d.ts +2 -2
  7. package/dist/types/commit/map-reduce/index.d.ts +3 -3
  8. package/dist/types/commit/map-reduce/map-phase.d.ts +2 -2
  9. package/dist/types/commit/map-reduce/reduce-phase.d.ts +2 -2
  10. package/dist/types/commit/model-selection.d.ts +10 -4
  11. package/dist/types/config/api-key-resolver.d.ts +34 -0
  12. package/dist/types/config/model-registry.d.ts +17 -1
  13. package/dist/types/config/settings-schema.d.ts +9 -0
  14. package/dist/types/dap/config.d.ts +14 -1
  15. package/dist/types/dap/types.d.ts +10 -0
  16. package/dist/types/lsp/utils.d.ts +3 -2
  17. package/dist/types/modes/components/chat-block.d.ts +64 -0
  18. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  19. package/dist/types/modes/components/overlay-box.d.ts +17 -0
  20. package/dist/types/modes/components/plan-review-overlay.d.ts +59 -0
  21. package/dist/types/modes/components/plan-toc.d.ts +41 -0
  22. package/dist/types/modes/components/read-tool-group.d.ts +2 -0
  23. package/dist/types/modes/components/transcript-container.d.ts +11 -0
  24. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  25. package/dist/types/modes/controllers/event-controller.d.ts +0 -1
  26. package/dist/types/modes/controllers/extension-ui-controller.d.ts +0 -1
  27. package/dist/types/modes/controllers/input-controller.d.ts +1 -1
  28. package/dist/types/modes/controllers/streaming-reveal.d.ts +22 -0
  29. package/dist/types/modes/controllers/tan-command-controller.d.ts +6 -0
  30. package/dist/types/modes/interactive-mode.d.ts +15 -5
  31. package/dist/types/modes/theme/theme.d.ts +1 -1
  32. package/dist/types/modes/types.d.ts +18 -5
  33. package/dist/types/modes/utils/copy-targets.d.ts +21 -1
  34. package/dist/types/plan-mode/approved-plan.d.ts +27 -8
  35. package/dist/types/plan-mode/plan-protection.d.ts +4 -4
  36. package/dist/types/sdk.d.ts +2 -0
  37. package/dist/types/session/agent-session.d.ts +21 -0
  38. package/dist/types/session/messages.d.ts +12 -0
  39. package/dist/types/session/session-manager.d.ts +3 -1
  40. package/dist/types/slash-commands/types.d.ts +4 -6
  41. package/dist/types/task/executor.d.ts +7 -0
  42. package/dist/types/task/index.d.ts +1 -0
  43. package/dist/types/task/render.d.ts +3 -2
  44. package/dist/types/tools/archive-reader.d.ts +5 -0
  45. package/dist/types/tools/ast-edit.d.ts +3 -0
  46. package/dist/types/tools/ast-grep.d.ts +3 -0
  47. package/dist/types/tools/bash.d.ts +1 -0
  48. package/dist/types/tools/find.d.ts +8 -4
  49. package/dist/types/tools/grouped-file-output.d.ts +95 -12
  50. package/dist/types/tools/memory-render.d.ts +4 -1
  51. package/dist/types/tools/plan-mode-guard.d.ts +8 -9
  52. package/dist/types/tools/render-utils.d.ts +5 -9
  53. package/dist/types/tools/search.d.ts +4 -0
  54. package/dist/types/tools/sqlite-reader.d.ts +1 -0
  55. package/dist/types/tools/todo.d.ts +3 -2
  56. package/dist/types/tools/write.d.ts +3 -0
  57. package/dist/types/tui/output-block.d.ts +16 -4
  58. package/dist/types/tui/status-line.d.ts +3 -0
  59. package/dist/types/utils/enhanced-paste.d.ts +20 -0
  60. package/dist/types/web/search/providers/kimi.d.ts +1 -1
  61. package/package.json +9 -9
  62. package/src/auto-thinking/classifier.ts +5 -1
  63. package/src/cli/dry-balance-cli.ts +52 -17
  64. package/src/cli/gallery-cli.ts +4 -1
  65. package/src/cli/gallery-fixtures/misc.ts +29 -0
  66. package/src/commit/analysis/conventional.ts +2 -2
  67. package/src/commit/analysis/summary.ts +2 -2
  68. package/src/commit/changelog/generate.ts +2 -2
  69. package/src/commit/changelog/index.ts +2 -2
  70. package/src/commit/map-reduce/index.ts +3 -3
  71. package/src/commit/map-reduce/map-phase.ts +2 -2
  72. package/src/commit/map-reduce/reduce-phase.ts +2 -2
  73. package/src/commit/model-selection.ts +33 -9
  74. package/src/commit/pipeline.ts +4 -4
  75. package/src/config/api-key-resolver.ts +58 -0
  76. package/src/config/model-registry.ts +25 -2
  77. package/src/config/settings-schema.ts +10 -0
  78. package/src/config/settings.ts +20 -2
  79. package/src/dap/config.ts +41 -2
  80. package/src/dap/defaults.json +1 -0
  81. package/src/dap/session.ts +1 -0
  82. package/src/dap/types.ts +10 -0
  83. package/src/debug/index.ts +40 -54
  84. package/src/edit/renderer.ts +82 -78
  85. package/src/eval/__tests__/llm-bridge.test.ts +90 -31
  86. package/src/eval/llm-bridge.ts +8 -3
  87. package/src/goals/tools/goal-tool.ts +36 -26
  88. package/src/internal-urls/docs-index.generated.ts +6 -6
  89. package/src/lsp/utils.ts +3 -2
  90. package/src/main.ts +9 -7
  91. package/src/memories/index.ts +12 -5
  92. package/src/mnemopi/backend.ts +5 -1
  93. package/src/modes/acp/acp-agent.ts +33 -26
  94. package/src/modes/components/assistant-message.ts +2 -9
  95. package/src/modes/components/chat-block.ts +111 -0
  96. package/src/modes/components/copy-selector.ts +1 -44
  97. package/src/modes/components/custom-editor.ts +23 -0
  98. package/src/modes/components/custom-message.ts +1 -3
  99. package/src/modes/components/execution-shared.ts +1 -2
  100. package/src/modes/components/hook-message.ts +1 -3
  101. package/src/modes/components/overlay-box.ts +108 -0
  102. package/src/modes/components/plan-review-overlay.ts +799 -0
  103. package/src/modes/components/plan-toc.ts +138 -0
  104. package/src/modes/components/read-tool-group.ts +20 -4
  105. package/src/modes/components/skill-message.ts +0 -1
  106. package/src/modes/components/tips.txt +1 -0
  107. package/src/modes/components/todo-reminder.ts +0 -2
  108. package/src/modes/components/tool-execution.ts +68 -88
  109. package/src/modes/components/transcript-container.ts +84 -24
  110. package/src/modes/components/user-message.ts +1 -2
  111. package/src/modes/controllers/command-controller-shared.ts +7 -6
  112. package/src/modes/controllers/command-controller.ts +57 -55
  113. package/src/modes/controllers/event-controller.ts +41 -40
  114. package/src/modes/controllers/extension-ui-controller.ts +10 -73
  115. package/src/modes/controllers/input-controller.ts +124 -119
  116. package/src/modes/controllers/mcp-command-controller.ts +69 -60
  117. package/src/modes/controllers/selector-controller.ts +23 -25
  118. package/src/modes/controllers/streaming-reveal.ts +212 -0
  119. package/src/modes/controllers/tan-command-controller.ts +173 -0
  120. package/src/modes/interactive-mode.ts +169 -94
  121. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  122. package/src/modes/theme/theme-schema.json +1 -1
  123. package/src/modes/theme/theme.ts +8 -4
  124. package/src/modes/types.ts +18 -7
  125. package/src/modes/utils/copy-targets.ts +133 -27
  126. package/src/modes/utils/ui-helpers.ts +44 -46
  127. package/src/plan-mode/approved-plan.ts +66 -43
  128. package/src/plan-mode/plan-protection.ts +4 -4
  129. package/src/prompts/system/background-tan-dispatch.md +8 -0
  130. package/src/prompts/system/plan-mode-active.md +67 -58
  131. package/src/prompts/system/plan-mode-approved.md +1 -1
  132. package/src/sdk.ts +11 -37
  133. package/src/session/agent-session.ts +82 -6
  134. package/src/session/messages.ts +26 -0
  135. package/src/session/session-manager.ts +13 -5
  136. package/src/slash-commands/builtin-registry.ts +36 -9
  137. package/src/slash-commands/types.ts +4 -6
  138. package/src/task/executor.ts +5 -2
  139. package/src/task/index.ts +4 -0
  140. package/src/task/render.ts +212 -147
  141. package/src/tools/archive-reader.ts +64 -0
  142. package/src/tools/ask.ts +119 -164
  143. package/src/tools/ast-edit.ts +98 -71
  144. package/src/tools/ast-grep.ts +37 -43
  145. package/src/tools/bash.ts +50 -6
  146. package/src/tools/debug.ts +20 -8
  147. package/src/tools/fetch.ts +297 -7
  148. package/src/tools/find.ts +44 -30
  149. package/src/tools/gh-renderer.ts +81 -42
  150. package/src/tools/grouped-file-output.ts +272 -48
  151. package/src/tools/image-gen.ts +150 -103
  152. package/src/tools/inspect-image-renderer.ts +63 -41
  153. package/src/tools/inspect-image.ts +8 -1
  154. package/src/tools/job.ts +3 -4
  155. package/src/tools/memory-render.ts +4 -1
  156. package/src/tools/plan-mode-guard.ts +21 -39
  157. package/src/tools/read.ts +23 -16
  158. package/src/tools/render-utils.ts +21 -37
  159. package/src/tools/resolve.ts +14 -0
  160. package/src/tools/search-tool-bm25.ts +36 -23
  161. package/src/tools/search.ts +80 -78
  162. package/src/tools/sqlite-reader.ts +9 -12
  163. package/src/tools/todo.ts +118 -52
  164. package/src/tools/write.ts +81 -62
  165. package/src/tui/output-block.ts +60 -13
  166. package/src/tui/status-line.ts +5 -1
  167. package/src/utils/commit-message-generator.ts +9 -1
  168. package/src/utils/enhanced-paste.ts +202 -0
  169. package/src/utils/title-generator.ts +2 -1
  170. package/src/web/search/providers/anthropic.ts +25 -19
  171. package/src/web/search/providers/exa.ts +11 -3
  172. package/src/web/search/providers/kimi.ts +28 -17
  173. package/src/web/search/providers/parallel.ts +35 -24
  174. package/src/web/search/providers/synthetic.ts +8 -6
  175. package/src/web/search/providers/tavily.ts +9 -8
  176. package/src/web/search/providers/zai.ts +8 -6
@@ -37,7 +37,9 @@ import type { MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../
37
37
  import type { OAuthCredential } from "../../session/auth-storage";
38
38
  import { shortenPath } from "../../tools/render-utils";
39
39
  import { openPath } from "../../utils/open";
40
+ import { ChatBlock } from "../components/chat-block";
40
41
  import { MCPAddWizard } from "../components/mcp-add-wizard";
42
+ import { TranscriptBlock } from "../components/transcript-container";
41
43
  import { parseCommandArgs } from "../shared";
42
44
  import { theme } from "../theme/theme";
43
45
  import type { InteractiveModeContext } from "../types";
@@ -49,6 +51,42 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string)
49
51
  return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer));
50
52
  }
51
53
 
54
+ /**
55
+ * Animated "Connecting to …" transcript block. Owns its spinner interval: it
56
+ * starts on mount and is cleared on {@link ChatBlock.finish}/dispose, so callers
57
+ * never juggle `setInterval`/`clearInterval` or `requestRender` by hand.
58
+ */
59
+ class McpConnectingBlock extends ChatBlock {
60
+ readonly #text: Text;
61
+
62
+ constructor(private readonly serverName: string) {
63
+ super();
64
+ this.addChild(new Spacer(1));
65
+ const frame = theme.spinnerFrames[0] ?? "|";
66
+ this.#text = new Text(theme.fg("muted", `${frame} Connecting to "${serverName}"...`), 1, 0);
67
+ this.addChild(this.#text);
68
+ }
69
+
70
+ protected override onMount(): void {
71
+ const frames = theme.spinnerFrames;
72
+ let frame = 0;
73
+ const interval = setInterval(() => {
74
+ frame++;
75
+ this.#text.setText(
76
+ theme.fg("muted", `${frames[frame % frames.length] ?? "|"} Connecting to "${this.serverName}"...`),
77
+ );
78
+ this.requestRender();
79
+ }, 80);
80
+ this.onCleanup(() => clearInterval(interval));
81
+ }
82
+
83
+ /** Replace the spinner line with a terminal status; pair with {@link finish}. */
84
+ setStatus(text: string): void {
85
+ this.#text.setText(text);
86
+ this.requestRender();
87
+ }
88
+ }
89
+
52
90
  /**
53
91
  * Outcome of {@link MCPCommandController}'s OAuth handler.
54
92
  *
@@ -547,63 +585,45 @@ export class MCPCommandController {
547
585
  },
548
586
  {
549
587
  onAuth: (info: { url: string; instructions?: string }) => {
550
- // Show auth URL prominently in chat
551
- this.ctx.chatContainer.addChild(new Spacer(1));
552
- this.ctx.chatContainer.addChild(
553
- new Text(theme.fg("accent", "━━━ OAuth Authorization Required ━━━"), 1, 0),
554
- );
555
- this.ctx.chatContainer.addChild(new Spacer(1));
556
- this.ctx.chatContainer.addChild(
557
- new Text(theme.fg("muted", "Preparing browser authorization..."), 1, 0),
558
- );
559
- this.ctx.chatContainer.addChild(new Spacer(1));
560
- this.ctx.chatContainer.addChild(
588
+ // Show auth URL prominently in chat as one block
589
+ const block = new TranscriptBlock();
590
+ this.ctx.present(block);
591
+ block.addChild(new Text(theme.fg("accent", "━━━ OAuth Authorization Required ━━━"), 1, 0));
592
+ block.addChild(new Spacer(1));
593
+ block.addChild(new Text(theme.fg("muted", "Preparing browser authorization..."), 1, 0));
594
+ block.addChild(new Spacer(1));
595
+ block.addChild(
561
596
  new Text(
562
597
  theme.fg("muted", "Waiting for authorization... (Press Ctrl+C to cancel, 5 minute timeout)"),
563
598
  1,
564
599
  0,
565
600
  ),
566
601
  );
567
- this.ctx.chatContainer.addChild(new Spacer(1));
568
- this.ctx.chatContainer.addChild(
569
- new Text(theme.fg("accent", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"), 1, 0),
570
- );
571
- this.ctx.ui.requestRender();
602
+ block.addChild(new Spacer(1));
603
+ block.addChild(new Text(theme.fg("accent", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"), 1, 0));
572
604
  // Try to open browser automatically
573
605
  try {
574
606
  openPath(info.url);
575
607
 
576
608
  // Show confirmation that browser should open
577
- this.ctx.chatContainer.addChild(new Spacer(1));
578
- this.ctx.chatContainer.addChild(
579
- new Text(theme.fg("success", "→ Opening browser automatically..."), 1, 0),
580
- );
581
- this.ctx.chatContainer.addChild(new Spacer(1));
582
- this.ctx.chatContainer.addChild(
583
- new Text(theme.fg("muted", "Alternative if browser did not open:"), 1, 0),
584
- );
585
- this.ctx.chatContainer.addChild(
586
- new Text(theme.fg("success", "Copy this exact URL in your browser:"), 1, 0),
587
- );
588
- this.ctx.chatContainer.addChild(new Text(theme.fg("accent", info.url), 1, 0));
609
+ block.addChild(new Spacer(1));
610
+ block.addChild(new Text(theme.fg("success", "→ Opening browser automatically..."), 1, 0));
611
+ block.addChild(new Spacer(1));
612
+ block.addChild(new Text(theme.fg("muted", "Alternative if browser did not open:"), 1, 0));
613
+ block.addChild(new Text(theme.fg("success", "Copy this exact URL in your browser:"), 1, 0));
614
+ block.addChild(new Text(theme.fg("accent", info.url), 1, 0));
589
615
  this.ctx.ui.requestRender();
590
616
  } catch (_error) {
591
617
  // Show error if browser doesn't open
592
- this.ctx.chatContainer.addChild(new Spacer(1));
593
- this.ctx.chatContainer.addChild(
594
- new Text(theme.fg("warning", " Could not open browser automatically"), 1, 0),
595
- );
596
- this.ctx.chatContainer.addChild(
597
- new Text(theme.fg("success", "Copy this exact URL in your browser:"), 1, 0),
598
- );
599
- this.ctx.chatContainer.addChild(new Text(theme.fg("accent", info.url), 1, 0));
618
+ block.addChild(new Spacer(1));
619
+ block.addChild(new Text(theme.fg("warning", "→ Could not open browser automatically"), 1, 0));
620
+ block.addChild(new Text(theme.fg("success", "Copy this exact URL in your browser:"), 1, 0));
621
+ block.addChild(new Text(theme.fg("accent", info.url), 1, 0));
600
622
  this.ctx.ui.requestRender();
601
623
  }
602
624
  },
603
625
  onProgress: (message: string) => {
604
- this.ctx.chatContainer.addChild(new Spacer(1));
605
- this.ctx.chatContainer.addChild(new Text(theme.fg("muted", message), 1, 0));
606
- this.ctx.ui.requestRender();
626
+ this.ctx.present([new Spacer(1), new Text(theme.fg("muted", message), 1, 0)]);
607
627
  },
608
628
  },
609
629
  );
@@ -611,9 +631,10 @@ export class MCPCommandController {
611
631
  // Execute OAuth flow with 5 minute timeout
612
632
  const credentials = await withTimeout(flow.login(), 5 * 60 * 1000, "OAuth flow timed out after 5 minutes");
613
633
 
614
- this.ctx.chatContainer.addChild(new Spacer(1));
615
- this.ctx.chatContainer.addChild(new Text(theme.fg("success", "✓ Authorization completed in browser."), 1, 0));
616
- this.ctx.ui.requestRender();
634
+ this.ctx.present([
635
+ new Spacer(1),
636
+ new Text(theme.fg("success", "✓ Authorization completed in browser."), 1, 0),
637
+ ]);
617
638
 
618
639
  // Generate a unique credential ID
619
640
  const credentialId = `mcp_oauth_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
@@ -766,19 +787,8 @@ export class MCPCommandController {
766
787
  ): Promise<"connected" | "connecting" | "disconnected"> {
767
788
  if (!this.ctx.mcpManager) return "disconnected";
768
789
 
769
- this.ctx.chatContainer.addChild(new Spacer(1));
770
- const frames = theme.spinnerFrames;
771
- const initialFrame = frames[0] ?? "|";
772
- const statusText = new Text(theme.fg("muted", `${initialFrame} Connecting to "${name}"...`), 1, 0);
773
- this.ctx.chatContainer.addChild(statusText);
774
- this.ctx.ui.requestRender();
775
-
776
- let frame = 0;
777
- const interval = setInterval(() => {
778
- statusText.setText(theme.fg("muted", `${frames[frame % frames.length]} Connecting to "${name}"...`));
779
- frame++;
780
- this.ctx.ui.requestRender();
781
- }, 80);
790
+ const block = new McpConnectingBlock(name);
791
+ this.ctx.present(block);
782
792
 
783
793
  try {
784
794
  try {
@@ -792,20 +802,19 @@ export class MCPCommandController {
792
802
  await this.ctx.session.refreshMCPTools(this.ctx.mcpManager.getTools());
793
803
  }
794
804
  if (state === "connected") {
795
- statusText.setText(theme.fg("success", `✓ Connected to "${name}"`));
805
+ block.setStatus(theme.fg("success", `✓ Connected to "${name}"`));
796
806
  } else if (state === "connecting") {
797
- statusText.setText(theme.fg("muted", `◌ "${name}" is still connecting...`));
807
+ block.setStatus(theme.fg("muted", `◌ "${name}" is still connecting...`));
798
808
  } else {
799
- statusText.setText(
809
+ block.setStatus(
800
810
  options?.suppressDisconnectedWarning
801
811
  ? theme.fg("muted", `◌ Connection check complete for "${name}"`)
802
812
  : theme.fg("warning", `⚠ Could not connect to "${name}" yet`),
803
813
  );
804
814
  }
805
- this.ctx.ui.requestRender();
806
815
  return state;
807
816
  } finally {
808
- clearInterval(interval);
817
+ block.finish();
809
818
  }
810
819
  }
811
820
 
@@ -50,6 +50,7 @@ import { SessionObserverOverlayComponent } from "../components/session-observer-
50
50
  import { SessionSelectorComponent } from "../components/session-selector";
51
51
  import { SettingsSelectorComponent } from "../components/settings-selector";
52
52
  import { ToolExecutionComponent } from "../components/tool-execution";
53
+ import { TranscriptBlock } from "../components/transcript-container";
53
54
  import { TreeSelectorComponent } from "../components/tree-selector";
54
55
  import { UserMessageSelectorComponent } from "../components/user-message-selector";
55
56
  import type { SessionObserverRegistry } from "../session-observer-registry";
@@ -931,28 +932,28 @@ export class SelectorController {
931
932
  try {
932
933
  await this.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, {
933
934
  onAuth: (info: { url: string; instructions?: string }) => {
934
- this.ctx.chatContainer.addChild(new Spacer(1));
935
- this.ctx.chatContainer.addChild(new Text(theme.fg("dim", info.url), 1, 0));
935
+ const block = new TranscriptBlock();
936
+ block.addChild(new Text(theme.fg("dim", info.url), 1, 0));
936
937
  const hyperlink = `\x1b]8;;${info.url}\x07Click here to login\x1b]8;;\x07`;
937
- this.ctx.chatContainer.addChild(new Text(theme.fg("accent", hyperlink), 1, 0));
938
+ block.addChild(new Text(theme.fg("accent", hyperlink), 1, 0));
938
939
  if (info.instructions) {
939
- this.ctx.chatContainer.addChild(new Spacer(1));
940
- this.ctx.chatContainer.addChild(new Text(theme.fg("warning", info.instructions), 1, 0));
940
+ block.addChild(new Spacer(1));
941
+ block.addChild(new Text(theme.fg("warning", info.instructions), 1, 0));
941
942
  }
942
943
  if (useManualInput) {
943
- this.ctx.chatContainer.addChild(new Spacer(1));
944
- this.ctx.chatContainer.addChild(new Text(theme.fg("dim", MANUAL_LOGIN_TIP), 1, 0));
944
+ block.addChild(new Spacer(1));
945
+ block.addChild(new Text(theme.fg("dim", MANUAL_LOGIN_TIP), 1, 0));
945
946
  }
946
- this.ctx.ui.requestRender();
947
+ this.ctx.present(block);
947
948
  this.ctx.openInBrowser(info.url);
948
949
  },
949
950
  onPrompt: async (prompt: { message: string; placeholder?: string }) => {
950
- this.ctx.chatContainer.addChild(new Spacer(1));
951
- this.ctx.chatContainer.addChild(new Text(theme.fg("warning", prompt.message), 1, 0));
951
+ const promptBlock = new TranscriptBlock();
952
+ promptBlock.addChild(new Text(theme.fg("warning", prompt.message), 1, 0));
952
953
  if (prompt.placeholder) {
953
- this.ctx.chatContainer.addChild(new Text(theme.fg("dim", prompt.placeholder), 1, 0));
954
+ promptBlock.addChild(new Text(theme.fg("dim", prompt.placeholder), 1, 0));
954
955
  }
955
- this.ctx.ui.requestRender();
956
+ this.ctx.present(promptBlock);
956
957
  const { promise, resolve } = Promise.withResolvers<string>();
957
958
  const codeInput = new Input();
958
959
  codeInput.onSubmit = () => {
@@ -969,18 +970,17 @@ export class SelectorController {
969
970
  return promise;
970
971
  },
971
972
  onProgress: (message: string) => {
972
- this.ctx.chatContainer.addChild(new Text(theme.fg("dim", message), 1, 0));
973
- this.ctx.ui.requestRender();
973
+ this.ctx.present(new Text(theme.fg("dim", message), 1, 0));
974
974
  },
975
975
  onManualCodeInput: useManualInput ? () => manualInput.waitForInput(providerId) : undefined,
976
976
  });
977
977
  await this.ctx.session.modelRegistry.refresh();
978
- this.ctx.chatContainer.addChild(new Spacer(1));
979
- this.ctx.chatContainer.addChild(
978
+ const block = new TranscriptBlock();
979
+ block.addChild(
980
980
  new Text(theme.fg("success", `${theme.status.success} Successfully logged in to ${providerId}`), 1, 0),
981
981
  );
982
- this.ctx.chatContainer.addChild(new Text(theme.fg("dim", `Credentials saved to ${getAgentDbPath()}`), 1, 0));
983
- this.ctx.ui.requestRender();
982
+ block.addChild(new Text(theme.fg("dim", `Credentials saved to ${getAgentDbPath()}`), 1, 0));
983
+ this.ctx.present(block);
984
984
  } catch (error: unknown) {
985
985
  this.ctx.showError(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
986
986
  } finally {
@@ -1002,20 +1002,18 @@ export class SelectorController {
1002
1002
 
1003
1003
  await authStorage.logout(providerId);
1004
1004
  await this.ctx.session.modelRegistry.refresh();
1005
- this.ctx.chatContainer.addChild(new Spacer(1));
1006
- this.ctx.chatContainer.addChild(
1005
+ const block = new TranscriptBlock();
1006
+ block.addChild(
1007
1007
  new Text(theme.fg("success", `${theme.status.success} Successfully logged out of ${providerId}`), 1, 0),
1008
1008
  );
1009
- this.ctx.chatContainer.addChild(
1010
- new Text(theme.fg("dim", `Credentials removed from ${getAgentDbPath()}`), 1, 0),
1011
- );
1009
+ block.addChild(new Text(theme.fg("dim", `Credentials removed from ${getAgentDbPath()}`), 1, 0));
1012
1010
  const remainingSource = authStorage.describeCredentialSource(providerId, this.ctx.session.sessionId);
1013
1011
  if (remainingSource) {
1014
- this.ctx.chatContainer.addChild(
1012
+ block.addChild(
1015
1013
  new Text(theme.fg("warning", `${providerId} is still authenticated via ${remainingSource}`), 1, 0),
1016
1014
  );
1017
1015
  }
1018
- this.ctx.ui.requestRender();
1016
+ this.ctx.present(block);
1019
1017
  } catch (error: unknown) {
1020
1018
  this.ctx.showError(`Logout failed: ${error instanceof Error ? error.message : String(error)}`);
1021
1019
  }
@@ -0,0 +1,212 @@
1
+ import type { AssistantMessage } from "@oh-my-pi/pi-ai";
2
+ import { getSegmenter } from "@oh-my-pi/pi-tui";
3
+ import type { AssistantMessageComponent } from "../components/assistant-message";
4
+
5
+ export const STREAMING_REVEAL_FRAME_MS = 1000 / 30;
6
+ export const MIN_STEP = 3;
7
+ export const CATCHUP_FRAMES = 8;
8
+
9
+ type AssistantContentBlock = AssistantMessage["content"][number];
10
+ type StreamingRevealComponent = Pick<AssistantMessageComponent, "updateContent">;
11
+
12
+ type StreamingRevealControllerOptions = {
13
+ getSmoothStreaming(): boolean;
14
+ getHideThinkingBlock(): boolean;
15
+ requestRender(): void;
16
+ };
17
+
18
+ function countGraphemes(text: string): number {
19
+ let count = 0;
20
+ for (const _segment of getSegmenter().segment(text)) {
21
+ count += 1;
22
+ }
23
+ return count;
24
+ }
25
+
26
+ function sliceGraphemes(text: string, units: number): string {
27
+ if (units <= 0 || text.length === 0) return "";
28
+ let count = 0;
29
+ for (const { index, segment } of getSegmenter().segment(text)) {
30
+ count += 1;
31
+ if (count >= units) {
32
+ const end = index + segment.length;
33
+ return end >= text.length ? text : text.slice(0, end);
34
+ }
35
+ }
36
+ return text;
37
+ }
38
+
39
+ export function visibleUnits(message: AssistantMessage, hideThinking: boolean): number {
40
+ let total = 0;
41
+ for (const block of message.content) {
42
+ if (block.type === "text") {
43
+ total += countGraphemes(block.text);
44
+ } else if (block.type === "thinking" && !hideThinking) {
45
+ total += countGraphemes(block.thinking);
46
+ }
47
+ }
48
+ return total;
49
+ }
50
+
51
+ function revealTextBlock(
52
+ block: Extract<AssistantContentBlock, { type: "text" }>,
53
+ remaining: number,
54
+ ): AssistantContentBlock {
55
+ if (remaining <= 0) return block.text.length === 0 ? block : { ...block, text: "" };
56
+ const units = countGraphemes(block.text);
57
+ if (remaining >= units) return block;
58
+ return { ...block, text: sliceGraphemes(block.text, remaining) };
59
+ }
60
+
61
+ function revealThinkingBlock(
62
+ block: Extract<AssistantContentBlock, { type: "thinking" }>,
63
+ remaining: number,
64
+ ): AssistantContentBlock {
65
+ if (remaining <= 0) return block.thinking.length === 0 ? block : { ...block, thinking: "" };
66
+ const units = countGraphemes(block.thinking);
67
+ if (remaining >= units) return block;
68
+ return { ...block, thinking: sliceGraphemes(block.thinking, remaining) };
69
+ }
70
+
71
+ export function buildDisplayMessage(
72
+ target: AssistantMessage,
73
+ revealed: number,
74
+ hideThinking: boolean,
75
+ ): AssistantMessage {
76
+ let remaining = Math.max(0, Math.floor(revealed));
77
+ const content: AssistantContentBlock[] = [];
78
+ for (const block of target.content) {
79
+ if (block.type === "text") {
80
+ content.push(revealTextBlock(block, remaining));
81
+ remaining = Math.max(0, remaining - countGraphemes(block.text));
82
+ } else if (block.type === "thinking" && !hideThinking) {
83
+ content.push(revealThinkingBlock(block, remaining));
84
+ remaining = Math.max(0, remaining - countGraphemes(block.thinking));
85
+ } else {
86
+ content.push(block);
87
+ }
88
+ }
89
+ return { ...target, content };
90
+ }
91
+
92
+ export function nextStep(backlog: number): number {
93
+ return Math.max(MIN_STEP, Math.ceil(Math.max(0, backlog) / CATCHUP_FRAMES));
94
+ }
95
+
96
+ export class StreamingRevealController {
97
+ readonly #getSmoothStreaming: () => boolean;
98
+ readonly #getHideThinkingBlock: () => boolean;
99
+ readonly #requestRender: () => void;
100
+ #target: AssistantMessage | undefined;
101
+ #component: StreamingRevealComponent | undefined;
102
+ #timer: NodeJS.Timeout | undefined;
103
+ #revealed = 0;
104
+ #hideThinkingBlock = false;
105
+ #smoothStreaming = true;
106
+
107
+ constructor(options: StreamingRevealControllerOptions) {
108
+ this.#getSmoothStreaming = options.getSmoothStreaming;
109
+ this.#getHideThinkingBlock = options.getHideThinkingBlock;
110
+ this.#requestRender = options.requestRender;
111
+ }
112
+
113
+ begin(component: StreamingRevealComponent, message: AssistantMessage): void {
114
+ this.stop();
115
+ this.#component = component;
116
+ this.#target = message;
117
+ this.#revealed = 0;
118
+ this.#hideThinkingBlock = this.#getHideThinkingBlock();
119
+ this.#smoothStreaming = this.#getSmoothStreaming();
120
+ if (!this.#smoothStreaming) {
121
+ component.updateContent(message);
122
+ return;
123
+ }
124
+ const total = visibleUnits(message, this.#hideThinkingBlock);
125
+ if (message.content.some(block => block.type === "toolCall")) {
126
+ // A tool call is a transcript-order boundary: finish any leading
127
+ // assistant text before EventController renders the separate tool card.
128
+ this.#revealed = total;
129
+ component.updateContent(buildDisplayMessage(message, this.#revealed, this.#hideThinkingBlock));
130
+ return;
131
+ }
132
+ this.#renderCurrent();
133
+ this.#syncTimer(total);
134
+ }
135
+
136
+ setTarget(message: AssistantMessage): void {
137
+ this.#target = message;
138
+ if (!this.#component) return;
139
+ if (!this.#smoothStreaming) {
140
+ this.#component.updateContent(message);
141
+ return;
142
+ }
143
+ const total = visibleUnits(message, this.#hideThinkingBlock);
144
+ if (message.content.some(block => block.type === "toolCall")) {
145
+ // A tool call is a transcript-order boundary: finish any leading
146
+ // assistant text before EventController renders the separate tool card.
147
+ this.#revealed = total;
148
+ this.#stopTimer();
149
+ this.#component.updateContent(buildDisplayMessage(message, this.#revealed, this.#hideThinkingBlock));
150
+ return;
151
+ }
152
+ if (this.#revealed > total) {
153
+ this.#revealed = total;
154
+ }
155
+ this.#renderCurrent();
156
+ this.#syncTimer(total);
157
+ }
158
+
159
+ stop(): void {
160
+ this.#stopTimer();
161
+ this.#target = undefined;
162
+ this.#component = undefined;
163
+ this.#revealed = 0;
164
+ }
165
+
166
+ #renderCurrent(): void {
167
+ if (!this.#target || !this.#component) return;
168
+ this.#component.updateContent(buildDisplayMessage(this.#target, this.#revealed, this.#hideThinkingBlock));
169
+ }
170
+
171
+ #syncTimer(total = this.#target ? visibleUnits(this.#target, this.#hideThinkingBlock) : 0): void {
172
+ if (!this.#target || !this.#component || this.#revealed >= total) {
173
+ this.#stopTimer();
174
+ return;
175
+ }
176
+ this.#startTimer();
177
+ }
178
+
179
+ #startTimer(): void {
180
+ if (this.#timer) return;
181
+ this.#timer = setInterval(() => {
182
+ this.#tick();
183
+ }, STREAMING_REVEAL_FRAME_MS);
184
+ this.#timer.unref?.();
185
+ }
186
+
187
+ #stopTimer(): void {
188
+ if (!this.#timer) return;
189
+ clearInterval(this.#timer);
190
+ this.#timer = undefined;
191
+ }
192
+
193
+ #tick(): void {
194
+ const target = this.#target;
195
+ const component = this.#component;
196
+ if (!target || !component) {
197
+ this.stop();
198
+ return;
199
+ }
200
+ const total = visibleUnits(target, this.#hideThinkingBlock);
201
+ if (this.#revealed >= total) {
202
+ this.#stopTimer();
203
+ return;
204
+ }
205
+ this.#revealed = Math.min(total, this.#revealed + nextStep(total - this.#revealed));
206
+ component.updateContent(buildDisplayMessage(target, this.#revealed, this.#hideThinkingBlock));
207
+ this.#requestRender();
208
+ if (this.#revealed >= total) {
209
+ this.#stopTimer();
210
+ }
211
+ }
212
+ }
@@ -0,0 +1,173 @@
1
+ import * as fs from "node:fs/promises";
2
+ import type { AssistantMessage } from "@oh-my-pi/pi-ai";
3
+ import { prompt, Snowflake } from "@oh-my-pi/pi-utils";
4
+ import backgroundTanDispatchPrompt from "../../prompts/system/background-tan-dispatch.md" with { type: "text" };
5
+ import { AgentRegistry, MAIN_AGENT_ID } from "../../registry/agent-registry";
6
+ import * as sdk from "../../sdk";
7
+ import type { AgentSession } from "../../session/agent-session";
8
+ import { SessionManager } from "../../session/session-manager";
9
+ import { createMCPProxyTools, createSubagentSettings } from "../../task/executor";
10
+ import type { InteractiveModeContext } from "../types";
11
+
12
+ const TAN_LABEL_PREVIEW_LENGTH = 80;
13
+
14
+ function previewWork(work: string): string {
15
+ const singleLine = work.trim().replace(/\s+/g, " ");
16
+ if (singleLine.length <= TAN_LABEL_PREVIEW_LENGTH) return singleLine;
17
+ return `${singleLine.slice(0, TAN_LABEL_PREVIEW_LENGTH - 1)}…`;
18
+ }
19
+
20
+ function extractAssistantText(message: AssistantMessage | undefined): string {
21
+ if (!message) return "";
22
+ return message.content
23
+ .filter(content => content.type === "text")
24
+ .map(content => content.text)
25
+ .join("")
26
+ .trim();
27
+ }
28
+
29
+ async function removeCloneSession(cloneFile: string): Promise<void> {
30
+ await Promise.allSettled([
31
+ fs.rm(cloneFile, { force: true }),
32
+ fs.rm(cloneFile.slice(0, -6), { recursive: true, force: true }),
33
+ ]);
34
+ }
35
+
36
+ export class TanCommandController {
37
+ constructor(private readonly ctx: InteractiveModeContext) {}
38
+
39
+ async start(work: string): Promise<void> {
40
+ const trimmedWork = work.trim();
41
+ if (!trimmedWork) {
42
+ this.ctx.showStatus("Usage: /tan <work>");
43
+ return;
44
+ }
45
+
46
+ const session = this.ctx.session;
47
+ if (session.isStreaming) {
48
+ this.ctx.showWarning("Wait for the current response to finish or abort it before using /tan.");
49
+ return;
50
+ }
51
+
52
+ const model = session.model;
53
+ if (!model) {
54
+ this.ctx.showError("No active model available for /tan.");
55
+ return;
56
+ }
57
+
58
+ const manager = session.asyncJobManager;
59
+ if (!manager) {
60
+ this.ctx.showError("Background jobs are disabled; enable async jobs to use /tan.");
61
+ return;
62
+ }
63
+
64
+ const parentFile = this.ctx.sessionManager.getSessionFile();
65
+ if (!parentFile) {
66
+ this.ctx.showError("/tan requires a persisted session.");
67
+ return;
68
+ }
69
+
70
+ const parentSessionId = session.sessionId;
71
+ const thinkingLevel = session.configuredThinkingLevel();
72
+ const systemPrompt = [...session.systemPrompt];
73
+ const toolNames = session.getActiveToolNames();
74
+ const modelRegistry = session.modelRegistry;
75
+ const ownerId = session.getAgentId() ?? MAIN_AGENT_ID;
76
+ const mcpManager = this.ctx.mcpManager;
77
+ const cwd = this.ctx.sessionManager.getCwd();
78
+ // Nest the clone inside the parent's artifact directory (like a subagent
79
+ // session) rather than as a top-level sibling, so it shares the parent's
80
+ // artifacts in place — no copy needed.
81
+ const sessionDir = parentFile.slice(0, -6);
82
+ const settings = createSubagentSettings(this.ctx.settings);
83
+ const customTools = mcpManager ? createMCPProxyTools(mcpManager) : undefined;
84
+ const enableLsp = this.ctx.settings.get("task.enableLsp") !== false;
85
+ const agentRegistry = AgentRegistry.global();
86
+ const cloneId = `Tan-${Snowflake.next()}`;
87
+ const label = `/tan ${previewWork(trimmedWork)}`;
88
+
89
+ await this.ctx.sessionManager.ensureOnDisk();
90
+ await this.ctx.sessionManager.flush();
91
+
92
+ let cloneFile = "";
93
+ let jobId = "";
94
+ try {
95
+ const cloneManager = await SessionManager.forkFrom(parentFile, cwd, sessionDir, undefined, {
96
+ suppressBreadcrumb: true,
97
+ });
98
+ cloneFile = cloneManager.getSessionFile() ?? "";
99
+ if (!cloneFile) throw new Error("Forked session did not create a session file.");
100
+
101
+ jobId = manager.register(
102
+ "task",
103
+ label,
104
+ async ({ signal }) => {
105
+ if (signal.aborted) throw new Error("Aborted before execution");
106
+
107
+ let clone: AgentSession | undefined;
108
+ try {
109
+ const created = await sdk.createAgentSession({
110
+ cwd,
111
+ sessionManager: cloneManager,
112
+ model,
113
+ thinkingLevel,
114
+ systemPrompt,
115
+ toolNames,
116
+ providerSessionId: `${parentSessionId}:tan:${Snowflake.next()}`,
117
+ providerPromptCacheKey: parentSessionId,
118
+ modelRegistry,
119
+ authStorage: modelRegistry.authStorage,
120
+ settings,
121
+ hasUI: false,
122
+ enableMCP: false,
123
+ customTools,
124
+ enableLsp,
125
+ agentId: cloneId,
126
+ agentDisplayName: "tan",
127
+ parentTaskPrefix: cloneId,
128
+ agentRegistry,
129
+ disableExtensionDiscovery: true,
130
+ });
131
+ clone = created.session;
132
+ const abortClone = () => {
133
+ void clone?.abort();
134
+ };
135
+ signal.addEventListener("abort", abortClone, { once: true });
136
+ try {
137
+ if (signal.aborted) {
138
+ abortClone();
139
+ throw new Error("Aborted before execution");
140
+ }
141
+ await clone.prompt(trimmedWork, { attribution: "user" });
142
+ await clone.waitForIdle();
143
+ return extractAssistantText(clone.getLastAssistantMessage()) || "(no output)";
144
+ } finally {
145
+ signal.removeEventListener("abort", abortClone);
146
+ }
147
+ } finally {
148
+ await clone?.dispose();
149
+ }
150
+ },
151
+ { ownerId },
152
+ );
153
+ } catch (error) {
154
+ if (cloneFile) await removeCloneSession(cloneFile);
155
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
156
+ return;
157
+ }
158
+
159
+ const content = prompt.render(backgroundTanDispatchPrompt, { jobId, work: trimmedWork });
160
+ await session.sendCustomMessage(
161
+ {
162
+ customType: "background-tan-dispatch",
163
+ content,
164
+ display: true,
165
+ attribution: "user",
166
+ details: { jobId, work: trimmedWork, sessionFile: cloneFile },
167
+ },
168
+ { triggerTurn: false },
169
+ );
170
+ this.ctx.rebuildChatFromMessages();
171
+ this.ctx.showStatus(`Dispatched background tan ${jobId}`);
172
+ }
173
+ }