@oh-my-pi/pi-coding-agent 15.10.0 → 15.10.2

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 (238) hide show
  1. package/CHANGELOG.md +142 -1
  2. package/dist/types/cli/dry-balance-cli.d.ts +15 -1
  3. package/dist/types/cli/startup-cwd.d.ts +2 -0
  4. package/dist/types/commands/launch.d.ts +3 -0
  5. package/dist/types/commit/analysis/conventional.d.ts +2 -2
  6. package/dist/types/commit/analysis/summary.d.ts +2 -2
  7. package/dist/types/commit/changelog/generate.d.ts +2 -2
  8. package/dist/types/commit/changelog/index.d.ts +2 -2
  9. package/dist/types/commit/map-reduce/index.d.ts +3 -3
  10. package/dist/types/commit/map-reduce/map-phase.d.ts +2 -2
  11. package/dist/types/commit/map-reduce/reduce-phase.d.ts +2 -2
  12. package/dist/types/commit/model-selection.d.ts +10 -4
  13. package/dist/types/config/api-key-resolver.d.ts +34 -0
  14. package/dist/types/config/keybindings.d.ts +2 -2
  15. package/dist/types/config/model-provider-priority.d.ts +1 -0
  16. package/dist/types/config/model-registry.d.ts +17 -1
  17. package/dist/types/config/model-resolver.d.ts +4 -1
  18. package/dist/types/config/settings-schema.d.ts +9 -0
  19. package/dist/types/config/settings.d.ts +7 -2
  20. package/dist/types/dap/config.d.ts +14 -1
  21. package/dist/types/dap/types.d.ts +10 -0
  22. package/dist/types/debug/report-bundle.d.ts +3 -0
  23. package/dist/types/edit/file-snapshot-store.d.ts +18 -10
  24. package/dist/types/eval/py/__tests__/prelude.test.d.ts +1 -0
  25. package/dist/types/extensibility/extensions/types.d.ts +4 -1
  26. package/dist/types/lsp/client.d.ts +10 -0
  27. package/dist/types/lsp/utils.d.ts +3 -2
  28. package/dist/types/main.d.ts +3 -9
  29. package/dist/types/mcp/tool-bridge.d.ts +2 -0
  30. package/dist/types/modes/components/chat-block.d.ts +64 -0
  31. package/dist/types/modes/components/custom-editor.d.ts +4 -1
  32. package/dist/types/modes/components/overlay-box.d.ts +17 -0
  33. package/dist/types/modes/components/plan-review-overlay.d.ts +59 -0
  34. package/dist/types/modes/components/plan-toc.d.ts +41 -0
  35. package/dist/types/modes/components/read-tool-group.d.ts +2 -0
  36. package/dist/types/modes/components/status-line.d.ts +2 -0
  37. package/dist/types/modes/components/transcript-container.d.ts +11 -0
  38. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  39. package/dist/types/modes/controllers/event-controller.d.ts +17 -1
  40. package/dist/types/modes/controllers/extension-ui-controller.d.ts +0 -1
  41. package/dist/types/modes/controllers/input-controller.d.ts +1 -1
  42. package/dist/types/modes/controllers/streaming-reveal.d.ts +22 -0
  43. package/dist/types/modes/controllers/tan-command-controller.d.ts +6 -0
  44. package/dist/types/modes/interactive-mode.d.ts +16 -5
  45. package/dist/types/modes/magic-keywords.d.ts +1 -1
  46. package/dist/types/modes/markdown-prose.d.ts +1 -1
  47. package/dist/types/modes/theme/theme.d.ts +1 -1
  48. package/dist/types/modes/types.d.ts +21 -5
  49. package/dist/types/modes/utils/copy-targets.d.ts +21 -1
  50. package/dist/types/modes/workflow.d.ts +3 -3
  51. package/dist/types/plan-mode/approved-plan.d.ts +27 -8
  52. package/dist/types/plan-mode/plan-protection.d.ts +4 -4
  53. package/dist/types/sdk.d.ts +2 -0
  54. package/dist/types/session/agent-session.d.ts +21 -0
  55. package/dist/types/session/auth-storage.d.ts +1 -1
  56. package/dist/types/session/messages.d.ts +12 -0
  57. package/dist/types/session/session-manager.d.ts +8 -3
  58. package/dist/types/slash-commands/types.d.ts +4 -6
  59. package/dist/types/task/executor.d.ts +17 -0
  60. package/dist/types/task/index.d.ts +1 -0
  61. package/dist/types/task/render.d.ts +3 -2
  62. package/dist/types/tools/archive-reader.d.ts +5 -0
  63. package/dist/types/tools/ast-edit.d.ts +3 -0
  64. package/dist/types/tools/ast-grep.d.ts +3 -0
  65. package/dist/types/tools/bash.d.ts +1 -0
  66. package/dist/types/tools/eval.d.ts +8 -0
  67. package/dist/types/tools/find.d.ts +8 -4
  68. package/dist/types/tools/gh-cache-invalidation.d.ts +6 -0
  69. package/dist/types/tools/github-cache.d.ts +12 -0
  70. package/dist/types/tools/grouped-file-output.d.ts +95 -12
  71. package/dist/types/tools/memory-render.d.ts +4 -1
  72. package/dist/types/tools/path-utils.d.ts +8 -0
  73. package/dist/types/tools/plan-mode-guard.d.ts +8 -9
  74. package/dist/types/tools/render-utils.d.ts +5 -9
  75. package/dist/types/tools/search.d.ts +6 -2
  76. package/dist/types/tools/sqlite-reader.d.ts +1 -0
  77. package/dist/types/tools/todo.d.ts +3 -2
  78. package/dist/types/tools/write.d.ts +3 -0
  79. package/dist/types/tools/yield.d.ts +8 -0
  80. package/dist/types/tui/output-block.d.ts +16 -4
  81. package/dist/types/tui/status-line.d.ts +3 -0
  82. package/dist/types/utils/enhanced-paste.d.ts +20 -0
  83. package/dist/types/web/search/providers/kimi.d.ts +1 -1
  84. package/package.json +9 -9
  85. package/src/auto-thinking/classifier.ts +5 -1
  86. package/src/cli/args.ts +3 -1
  87. package/src/cli/dry-balance-cli.ts +54 -21
  88. package/src/cli/gallery-cli.ts +4 -1
  89. package/src/cli/gallery-fixtures/misc.ts +29 -0
  90. package/src/cli/startup-cwd.ts +68 -0
  91. package/src/commands/launch.ts +3 -0
  92. package/src/commit/analysis/conventional.ts +2 -2
  93. package/src/commit/analysis/summary.ts +2 -2
  94. package/src/commit/changelog/generate.ts +2 -2
  95. package/src/commit/changelog/index.ts +2 -2
  96. package/src/commit/map-reduce/index.ts +3 -3
  97. package/src/commit/map-reduce/map-phase.ts +2 -2
  98. package/src/commit/map-reduce/reduce-phase.ts +2 -2
  99. package/src/commit/model-selection.ts +36 -11
  100. package/src/commit/pipeline.ts +4 -4
  101. package/src/config/api-key-resolver.ts +58 -0
  102. package/src/config/model-provider-priority.ts +55 -0
  103. package/src/config/model-registry.ts +29 -24
  104. package/src/config/model-resolver.ts +39 -7
  105. package/src/config/settings-schema.ts +10 -0
  106. package/src/config/settings.ts +106 -43
  107. package/src/dap/config.ts +41 -2
  108. package/src/dap/defaults.json +1 -0
  109. package/src/dap/session.ts +1 -0
  110. package/src/dap/types.ts +10 -0
  111. package/src/debug/index.ts +47 -53
  112. package/src/debug/raw-sse-buffer.ts +7 -4
  113. package/src/debug/report-bundle.ts +9 -0
  114. package/src/edit/file-snapshot-store.ts +33 -1
  115. package/src/edit/hashline/filesystem.ts +2 -1
  116. package/src/edit/renderer.ts +82 -78
  117. package/src/eval/__tests__/llm-bridge.test.ts +110 -31
  118. package/src/eval/js/context-manager.ts +32 -15
  119. package/src/eval/llm-bridge.ts +22 -6
  120. package/src/eval/py/__tests__/prelude.test.ts +19 -0
  121. package/src/eval/py/executor.ts +23 -11
  122. package/src/eval/py/prelude.py +1 -1
  123. package/src/extensibility/extensions/types.ts +10 -1
  124. package/src/goals/tools/goal-tool.ts +36 -26
  125. package/src/internal-urls/docs-index.generated.ts +8 -8
  126. package/src/lsp/client.ts +23 -11
  127. package/src/lsp/config.ts +11 -1
  128. package/src/lsp/index.ts +61 -9
  129. package/src/lsp/utils.ts +3 -2
  130. package/src/main.ts +100 -72
  131. package/src/mcp/tool-bridge.ts +2 -0
  132. package/src/memories/index.ts +14 -7
  133. package/src/mnemopi/backend.ts +5 -1
  134. package/src/modes/acp/acp-agent.ts +33 -26
  135. package/src/modes/components/assistant-message.ts +2 -9
  136. package/src/modes/components/chat-block.ts +111 -0
  137. package/src/modes/components/copy-selector.ts +1 -44
  138. package/src/modes/components/custom-editor.ts +164 -109
  139. package/src/modes/components/custom-message.ts +1 -3
  140. package/src/modes/components/execution-shared.ts +1 -2
  141. package/src/modes/components/hook-message.ts +1 -3
  142. package/src/modes/components/model-selector.ts +59 -13
  143. package/src/modes/components/oauth-selector.ts +33 -7
  144. package/src/modes/components/overlay-box.ts +108 -0
  145. package/src/modes/components/plan-review-overlay.ts +799 -0
  146. package/src/modes/components/plan-toc.ts +138 -0
  147. package/src/modes/components/read-tool-group.ts +20 -4
  148. package/src/modes/components/skill-message.ts +0 -1
  149. package/src/modes/components/status-line.ts +19 -4
  150. package/src/modes/components/tips.txt +2 -1
  151. package/src/modes/components/todo-reminder.ts +0 -2
  152. package/src/modes/components/tool-execution.ts +68 -88
  153. package/src/modes/components/transcript-container.ts +84 -24
  154. package/src/modes/components/user-message.ts +2 -3
  155. package/src/modes/controllers/command-controller-shared.ts +7 -6
  156. package/src/modes/controllers/command-controller.ts +57 -55
  157. package/src/modes/controllers/event-controller.ts +67 -40
  158. package/src/modes/controllers/extension-ui-controller.ts +10 -73
  159. package/src/modes/controllers/input-controller.ts +170 -126
  160. package/src/modes/controllers/mcp-command-controller.ts +69 -60
  161. package/src/modes/controllers/selector-controller.ts +23 -25
  162. package/src/modes/controllers/streaming-reveal.ts +212 -0
  163. package/src/modes/controllers/tan-command-controller.ts +173 -0
  164. package/src/modes/interactive-mode.ts +274 -112
  165. package/src/modes/magic-keywords.ts +1 -1
  166. package/src/modes/markdown-prose.ts +1 -1
  167. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  168. package/src/modes/theme/shimmer.ts +20 -9
  169. package/src/modes/theme/theme-schema.json +1 -1
  170. package/src/modes/theme/theme.ts +8 -4
  171. package/src/modes/types.ts +21 -7
  172. package/src/modes/utils/copy-targets.ts +133 -27
  173. package/src/modes/utils/ui-helpers.ts +44 -46
  174. package/src/modes/workflow.ts +10 -10
  175. package/src/plan-mode/approved-plan.ts +66 -43
  176. package/src/plan-mode/plan-protection.ts +4 -4
  177. package/src/prompts/system/background-tan-dispatch.md +8 -0
  178. package/src/prompts/system/plan-mode-active.md +67 -58
  179. package/src/prompts/system/plan-mode-approved.md +1 -1
  180. package/src/prompts/system/workflow-notice.md +1 -1
  181. package/src/prompts/tools/bash.md +9 -0
  182. package/src/prompts/tools/browser.md +1 -1
  183. package/src/prompts/tools/eval.md +2 -1
  184. package/src/prompts/tools/read.md +2 -2
  185. package/src/sdk.ts +37 -46
  186. package/src/session/agent-session.ts +119 -18
  187. package/src/session/auth-storage.ts +2 -0
  188. package/src/session/messages.ts +26 -0
  189. package/src/session/session-manager.ts +109 -28
  190. package/src/slash-commands/builtin-registry.ts +36 -9
  191. package/src/slash-commands/types.ts +4 -6
  192. package/src/task/executor.ts +76 -38
  193. package/src/task/index.ts +4 -0
  194. package/src/task/render.ts +211 -147
  195. package/src/tools/archive-reader.ts +64 -0
  196. package/src/tools/ask.ts +119 -164
  197. package/src/tools/ast-edit.ts +98 -71
  198. package/src/tools/ast-grep.ts +37 -43
  199. package/src/tools/bash.ts +57 -6
  200. package/src/tools/browser/tab-supervisor.ts +13 -1
  201. package/src/tools/browser/tab-worker.ts +33 -4
  202. package/src/tools/debug.ts +20 -8
  203. package/src/tools/eval.ts +13 -2
  204. package/src/tools/fetch.ts +297 -7
  205. package/src/tools/find.ts +51 -30
  206. package/src/tools/gh-cache-invalidation.ts +200 -0
  207. package/src/tools/gh-renderer.ts +81 -42
  208. package/src/tools/github-cache.ts +25 -0
  209. package/src/tools/grouped-file-output.ts +272 -48
  210. package/src/tools/image-gen.ts +150 -103
  211. package/src/tools/inspect-image-renderer.ts +63 -41
  212. package/src/tools/inspect-image.ts +10 -3
  213. package/src/tools/job.ts +3 -4
  214. package/src/tools/memory-render.ts +4 -1
  215. package/src/tools/path-utils.ts +28 -2
  216. package/src/tools/plan-mode-guard.ts +66 -39
  217. package/src/tools/read.ts +48 -28
  218. package/src/tools/render-utils.ts +21 -37
  219. package/src/tools/resolve.ts +14 -0
  220. package/src/tools/search-tool-bm25.ts +36 -23
  221. package/src/tools/search.ts +118 -81
  222. package/src/tools/sqlite-reader.ts +9 -12
  223. package/src/tools/todo.ts +118 -52
  224. package/src/tools/write.ts +83 -64
  225. package/src/tools/yield.ts +10 -1
  226. package/src/tui/output-block.ts +60 -13
  227. package/src/tui/status-line.ts +5 -1
  228. package/src/utils/commit-message-generator.ts +11 -3
  229. package/src/utils/enhanced-paste.ts +230 -0
  230. package/src/utils/title-generator.ts +2 -1
  231. package/src/web/search/providers/anthropic.ts +25 -19
  232. package/src/web/search/providers/codex.ts +37 -8
  233. package/src/web/search/providers/exa.ts +11 -3
  234. package/src/web/search/providers/kimi.ts +28 -17
  235. package/src/web/search/providers/parallel.ts +35 -24
  236. package/src/web/search/providers/synthetic.ts +8 -6
  237. package/src/web/search/providers/tavily.ts +9 -8
  238. package/src/web/search/providers/zai.ts +8 -6
@@ -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
+ }