@oh-my-pi/pi-coding-agent 16.1.23 → 16.2.0

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 (254) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/cli.js +3817 -3735
  3. package/dist/types/advisor/__tests__/emission-guard.test.d.ts +1 -0
  4. package/dist/types/advisor/emission-guard.d.ts +73 -0
  5. package/dist/types/advisor/index.d.ts +1 -0
  6. package/dist/types/advisor/runtime.d.ts +7 -0
  7. package/dist/types/advisor/watchdog.d.ts +2 -0
  8. package/dist/types/cli/flag-tables.d.ts +1 -0
  9. package/dist/types/cli/gallery-fixtures/search.d.ts +1 -1
  10. package/dist/types/collab/display-name.d.ts +3 -0
  11. package/dist/types/collab/host.d.ts +0 -2
  12. package/dist/types/commands/worktree.d.ts +0 -3
  13. package/dist/types/config/inline-tool-descriptors-mode.d.ts +13 -0
  14. package/dist/types/config/model-discovery.d.ts +6 -1
  15. package/dist/types/config/model-registry.d.ts +6 -2
  16. package/dist/types/config/model-resolver.d.ts +12 -0
  17. package/dist/types/config/models-config-schema.d.ts +29 -2
  18. package/dist/types/config/models-config.d.ts +22 -1
  19. package/dist/types/config/settings-schema.d.ts +91 -21
  20. package/dist/types/config/settings.d.ts +2 -0
  21. package/dist/types/dap/config.d.ts +1 -1
  22. package/dist/types/edit/hashline/filesystem.d.ts +4 -2
  23. package/dist/types/edit/renderer.d.ts +4 -0
  24. package/dist/types/extensibility/extensions/types.d.ts +17 -17
  25. package/dist/types/extensibility/hooks/types.d.ts +10 -10
  26. package/dist/types/extensibility/plugins/marketplace/types.d.ts +1 -0
  27. package/dist/types/internal-urls/__tests__/ssh-protocol.test.d.ts +1 -0
  28. package/dist/types/internal-urls/index.d.ts +1 -0
  29. package/dist/types/internal-urls/registry-helpers.d.ts +2 -0
  30. package/dist/types/internal-urls/router.d.ts +1 -1
  31. package/dist/types/internal-urls/ssh-protocol.d.ts +10 -0
  32. package/dist/types/internal-urls/types.d.ts +19 -2
  33. package/dist/types/irc/bus.d.ts +6 -0
  34. package/dist/types/modes/components/__tests__/move-overlay.test.d.ts +1 -0
  35. package/dist/types/modes/components/custom-editor.d.ts +7 -0
  36. package/dist/types/modes/components/move-overlay.d.ts +23 -0
  37. package/dist/types/modes/components/plugin-selector.d.ts +2 -1
  38. package/dist/types/modes/components/queue-mode-selector.d.ts +2 -1
  39. package/dist/types/modes/components/select-list-mouse-routing.d.ts +2 -0
  40. package/dist/types/modes/components/settings-defs.d.ts +4 -1
  41. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  42. package/dist/types/modes/components/show-images-selector.d.ts +2 -1
  43. package/dist/types/modes/components/status-line/component.d.ts +2 -0
  44. package/dist/types/modes/components/status-line/types.d.ts +2 -0
  45. package/dist/types/modes/components/theme-selector.d.ts +2 -1
  46. package/dist/types/modes/components/thinking-selector.d.ts +2 -1
  47. package/dist/types/modes/controllers/command-controller.d.ts +10 -1
  48. package/dist/types/modes/controllers/streaming-reveal.d.ts +6 -0
  49. package/dist/types/modes/interactive-mode.d.ts +10 -1
  50. package/dist/types/modes/internal-url-autocomplete.d.ts +1 -1
  51. package/dist/types/modes/running-subagent-badge.d.ts +6 -0
  52. package/dist/types/modes/theme/theme.d.ts +1 -1
  53. package/dist/types/modes/types.d.ts +9 -1
  54. package/dist/types/sdk.d.ts +2 -2
  55. package/dist/types/session/agent-session.d.ts +2 -1
  56. package/dist/types/session/session-manager.d.ts +13 -0
  57. package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
  58. package/dist/types/ssh/__tests__/connection-manager-args.test.d.ts +1 -0
  59. package/dist/types/ssh/__tests__/file-transfer-posix-guard.test.d.ts +1 -0
  60. package/dist/types/ssh/connection-manager.d.ts +2 -0
  61. package/dist/types/ssh/file-transfer.d.ts +79 -0
  62. package/dist/types/ssh/utils.d.ts +6 -0
  63. package/dist/types/system-prompt.d.ts +3 -0
  64. package/dist/types/task/executor.d.ts +16 -0
  65. package/dist/types/tiny/text.d.ts +1 -1
  66. package/dist/types/tools/builtin-names.d.ts +5 -1
  67. package/dist/types/tools/{find.d.ts → glob.d.ts} +15 -15
  68. package/dist/types/tools/{search.d.ts → grep.d.ts} +14 -14
  69. package/dist/types/tools/index.d.ts +3 -3
  70. package/dist/types/tools/path-utils.d.ts +29 -0
  71. package/dist/types/tools/read.d.ts +2 -2
  72. package/dist/types/tools/render-utils.d.ts +8 -0
  73. package/dist/types/tools/renderers.d.ts +11 -0
  74. package/dist/types/tools/ssh.d.ts +2 -0
  75. package/dist/types/tools/todo.d.ts +0 -16
  76. package/dist/types/tools/write.d.ts +2 -2
  77. package/dist/types/utils/active-repo-context.d.ts +8 -0
  78. package/dist/types/utils/image-resize.d.ts +1 -0
  79. package/dist/types/utils/markit-cache.d.ts +23 -0
  80. package/dist/types/utils/markit.d.ts +5 -1
  81. package/dist/types/utils/prompt-path.d.ts +1 -0
  82. package/dist/types/web/search/providers/duckduckgo.d.ts +14 -0
  83. package/dist/types/web/search/providers/firecrawl.d.ts +28 -0
  84. package/dist/types/web/search/providers/tinyfish.d.ts +29 -0
  85. package/dist/types/web/search/providers/xai.d.ts +13 -0
  86. package/dist/types/web/search/types.d.ts +18 -2
  87. package/package.json +30 -15
  88. package/scripts/build-binary.ts +9 -9
  89. package/scripts/bundle-dist.ts +2 -2
  90. package/src/advisor/__tests__/advisor.test.ts +40 -4
  91. package/src/advisor/__tests__/emission-guard.test.ts +147 -0
  92. package/src/advisor/advise-tool.ts +1 -1
  93. package/src/advisor/emission-guard.ts +172 -0
  94. package/src/advisor/index.ts +1 -0
  95. package/src/advisor/runtime.ts +11 -0
  96. package/src/advisor/watchdog.ts +12 -1
  97. package/src/cli/args.ts +5 -2
  98. package/src/cli/auth-broker-cli.ts +17 -0
  99. package/src/cli/config-cli.ts +4 -0
  100. package/src/cli/flag-tables.ts +7 -4
  101. package/src/cli/gallery-cli.ts +14 -2
  102. package/src/cli/gallery-fixtures/edit.ts +60 -0
  103. package/src/cli/gallery-fixtures/fs.ts +17 -17
  104. package/src/cli/gallery-fixtures/search.ts +4 -4
  105. package/src/cli/usage-cli.ts +20 -1
  106. package/src/cli/web-search-cli.ts +1 -1
  107. package/src/collab/display-name.ts +13 -0
  108. package/src/collab/guest.ts +5 -1
  109. package/src/collab/host.ts +2 -13
  110. package/src/commands/worktree.ts +6 -0
  111. package/src/config/inline-tool-descriptors-mode.ts +27 -0
  112. package/src/config/model-discovery.ts +77 -8
  113. package/src/config/model-registry.ts +89 -11
  114. package/src/config/model-resolver.ts +39 -1
  115. package/src/config/models-config-schema.ts +41 -5
  116. package/src/config/models-config.ts +4 -1
  117. package/src/config/settings-schema.ts +85 -27
  118. package/src/config/settings.ts +172 -1
  119. package/src/cursor.ts +1 -1
  120. package/src/dap/config.ts +152 -8
  121. package/src/dap/session.ts +1 -1
  122. package/src/discovery/helpers.ts +3 -1
  123. package/src/edit/hashline/diff.ts +14 -3
  124. package/src/edit/hashline/execute.ts +42 -6
  125. package/src/edit/hashline/filesystem.ts +38 -2
  126. package/src/edit/index.ts +1 -0
  127. package/src/edit/modes/patch.ts +11 -1
  128. package/src/edit/renderer.ts +140 -13
  129. package/src/eval/__tests__/agent-bridge.test.ts +101 -0
  130. package/src/eval/agent-bridge.ts +26 -3
  131. package/src/eval/js/tool-bridge.ts +2 -2
  132. package/src/export/html/index.ts +1 -1
  133. package/src/export/html/template.js +3 -1
  134. package/src/export/html/tool-views.generated.js +20 -20
  135. package/src/extensibility/extensions/types.ts +22 -22
  136. package/src/extensibility/hooks/types.ts +11 -11
  137. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +8 -3
  138. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +25 -10
  139. package/src/extensibility/plugins/marketplace/manager.ts +22 -0
  140. package/src/extensibility/plugins/marketplace/types.ts +1 -0
  141. package/src/internal-urls/__tests__/ssh-protocol.test.ts +331 -0
  142. package/src/internal-urls/docs-index.generated.txt +2 -2
  143. package/src/internal-urls/docs-index.ts +2 -3
  144. package/src/internal-urls/index.ts +1 -0
  145. package/src/internal-urls/registry-helpers.ts +19 -4
  146. package/src/internal-urls/router.ts +5 -3
  147. package/src/internal-urls/ssh-protocol.ts +367 -0
  148. package/src/internal-urls/types.ts +19 -2
  149. package/src/irc/bus.ts +11 -3
  150. package/src/mcp/tool-bridge.ts +32 -2
  151. package/src/memories/index.ts +1 -1
  152. package/src/modes/acp/acp-event-mapper.ts +2 -2
  153. package/src/modes/components/__tests__/move-overlay.test.ts +166 -0
  154. package/src/modes/components/agent-hub.ts +3 -0
  155. package/src/modes/components/agent-transcript-viewer.ts +9 -7
  156. package/src/modes/components/custom-editor.ts +90 -5
  157. package/src/modes/components/move-overlay.ts +282 -0
  158. package/src/modes/components/plan-review-overlay.ts +35 -35
  159. package/src/modes/components/plugin-selector.ts +6 -1
  160. package/src/modes/components/queue-mode-selector.ts +6 -1
  161. package/src/modes/components/select-list-mouse-routing.ts +35 -0
  162. package/src/modes/components/session-selector.ts +11 -10
  163. package/src/modes/components/settings-defs.ts +14 -1
  164. package/src/modes/components/settings-selector.ts +196 -29
  165. package/src/modes/components/show-images-selector.ts +6 -1
  166. package/src/modes/components/status-line/component.ts +108 -28
  167. package/src/modes/components/status-line/segments.ts +5 -1
  168. package/src/modes/components/status-line/types.ts +2 -0
  169. package/src/modes/components/theme-selector.ts +6 -1
  170. package/src/modes/components/thinking-selector.ts +6 -1
  171. package/src/modes/components/tool-execution.ts +25 -9
  172. package/src/modes/components/tree-selector.ts +5 -5
  173. package/src/modes/controllers/command-controller.ts +140 -47
  174. package/src/modes/controllers/event-controller.ts +59 -3
  175. package/src/modes/controllers/input-controller.ts +68 -2
  176. package/src/modes/controllers/selector-controller.ts +5 -2
  177. package/src/modes/controllers/streaming-reveal.ts +17 -0
  178. package/src/modes/controllers/tool-args-reveal.ts +1 -1
  179. package/src/modes/interactive-mode.ts +116 -50
  180. package/src/modes/internal-url-autocomplete.ts +17 -2
  181. package/src/modes/prompt-action-autocomplete.ts +3 -1
  182. package/src/modes/running-subagent-badge.ts +13 -0
  183. package/src/modes/setup-wizard/scenes/glyph.ts +2 -13
  184. package/src/modes/setup-wizard/scenes/providers.ts +2 -1
  185. package/src/modes/setup-wizard/scenes/theme.ts +6 -12
  186. package/src/modes/setup-wizard/scenes/web-search.ts +8 -13
  187. package/src/modes/setup-wizard/wizard-overlay.ts +6 -5
  188. package/src/modes/theme/theme.ts +9 -1
  189. package/src/modes/types.ts +9 -1
  190. package/src/modes/utils/interactive-context-helpers.ts +1 -1
  191. package/src/priority.json +15 -0
  192. package/src/prompts/advisor/active-repo-watchdog.md +6 -0
  193. package/src/prompts/advisor/system.md +21 -7
  194. package/src/prompts/agents/designer.md +1 -1
  195. package/src/prompts/agents/explore.md +1 -1
  196. package/src/prompts/agents/librarian.md +2 -2
  197. package/src/prompts/agents/plan.md +2 -2
  198. package/src/prompts/agents/reviewer.md +1 -1
  199. package/src/prompts/agents/task.md +2 -2
  200. package/src/prompts/system/active-repo-context.md +4 -0
  201. package/src/prompts/system/gemini-tool-call-reminder.md +9 -0
  202. package/src/prompts/system/plan-mode-active.md +3 -3
  203. package/src/prompts/system/project-prompt.md +2 -2
  204. package/src/prompts/system/system-prompt.md +6 -6
  205. package/src/prompts/tools/bash.md +2 -2
  206. package/src/prompts/tools/checkpoint.md +1 -1
  207. package/src/prompts/tools/{find.md → glob.md} +1 -1
  208. package/src/prompts/tools/{search.md → grep.md} +3 -3
  209. package/src/prompts/tools/read.md +4 -2
  210. package/src/sdk.ts +55 -25
  211. package/src/session/agent-session.ts +508 -132
  212. package/src/session/session-history-format.ts +2 -2
  213. package/src/session/session-manager.ts +46 -0
  214. package/src/slash-commands/builtin-registry.ts +138 -20
  215. package/src/slash-commands/helpers/usage-report.ts +23 -2
  216. package/src/ssh/__tests__/connection-manager-args.test.ts +69 -0
  217. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +68 -0
  218. package/src/ssh/connection-manager.ts +12 -15
  219. package/src/ssh/file-transfer.ts +209 -0
  220. package/src/ssh/utils.ts +24 -0
  221. package/src/system-prompt.ts +56 -28
  222. package/src/task/executor.ts +96 -36
  223. package/src/task/index.ts +3 -3
  224. package/src/task/render.ts +14 -13
  225. package/src/task/worktree.ts +38 -1
  226. package/src/tiny/text.ts +49 -4
  227. package/src/tiny/worker.ts +3 -3
  228. package/src/tools/bash.ts +2 -2
  229. package/src/tools/builtin-names.ts +26 -2
  230. package/src/tools/{find.ts → glob.ts} +48 -42
  231. package/src/tools/{search.ts → grep.ts} +311 -129
  232. package/src/tools/index.ts +13 -14
  233. package/src/tools/irc.ts +6 -2
  234. package/src/tools/path-utils.ts +64 -1
  235. package/src/tools/read.ts +35 -6
  236. package/src/tools/render-utils.ts +14 -0
  237. package/src/tools/renderers.ts +15 -4
  238. package/src/tools/ssh.ts +17 -2
  239. package/src/tools/todo.ts +4 -26
  240. package/src/tools/write.ts +25 -9
  241. package/src/utils/active-repo-context.ts +143 -0
  242. package/src/utils/image-resize.ts +88 -7
  243. package/src/utils/lang-from-path.ts +3 -3
  244. package/src/utils/markit-cache.ts +166 -0
  245. package/src/utils/markit.ts +86 -18
  246. package/src/utils/prompt-path.ts +3 -0
  247. package/src/utils/title-generator.ts +1 -1
  248. package/src/web/search/provider.ts +54 -34
  249. package/src/web/search/providers/duckduckgo.ts +140 -0
  250. package/src/web/search/providers/firecrawl.ts +144 -0
  251. package/src/web/search/providers/tinyfish.ts +159 -0
  252. package/src/web/search/providers/xai.ts +292 -0
  253. package/src/web/search/providers/zai.ts +142 -56
  254. package/src/web/search/types.ts +6 -2
@@ -6,6 +6,7 @@ import {
6
6
  getEnvApiKey,
7
7
  getProviderDetails,
8
8
  type ProviderDetails,
9
+ resolveUsedFraction,
9
10
  type UsageLimit,
10
11
  type UsageReport,
11
12
  } from "@oh-my-pi/pi-ai";
@@ -30,6 +31,7 @@ import { BashExecutionComponent } from "../../modes/components/bash-execution";
30
31
  import { BorderedLoader } from "../../modes/components/bordered-loader";
31
32
  import { DynamicBorder } from "../../modes/components/dynamic-border";
32
33
  import { EvalExecutionComponent } from "../../modes/components/eval-execution";
34
+ import { MoveOverlay, type MoveOverlayResult } from "../../modes/components/move-overlay";
33
35
  import { TranscriptBlock } from "../../modes/components/transcript-container";
34
36
  import { getMarkdownTheme, getSymbolTheme, theme } from "../../modes/theme/theme";
35
37
  import type { InteractiveModeContext } from "../../modes/types";
@@ -40,6 +42,7 @@ import type { AsyncJobSnapshotItem } from "../../session/agent-session";
40
42
  import type { AuthStorage, OAuthAccountIdentity } from "../../session/auth-storage";
41
43
  import type { CompactMode } from "../../session/compact-modes";
42
44
  import type { NewSessionOptions } from "../../session/session-entries";
45
+ import { SessionManager } from "../../session/session-manager";
43
46
  import { formatShakeSummary, type ShakeMode, type ShakeResult } from "../../session/shake-types";
44
47
  import { limitMatchesActiveAccount } from "../../slash-commands/helpers/active-oauth-account";
45
48
  import { outputMeta } from "../../tools/output-meta";
@@ -910,13 +913,34 @@ export class CommandController {
910
913
  ]);
911
914
  }
912
915
 
913
- async handleMoveCommand(targetPath: string): Promise<void> {
916
+ /**
917
+ * `/move` — switch to a fresh empty session in a different directory.
918
+ *
919
+ * With no `targetPath` (TUI only), opens an autocomplete overlay so the user
920
+ * can pick or type a directory. With a `targetPath`, resolves it directly.
921
+ * If the target directory does not exist, the user is asked whether to create
922
+ * it. A brand-new empty session is then started in the target directory and
923
+ * the current session is left behind (resumable via `/resume`).
924
+ */
925
+ async handleMoveCommand(targetPath?: string): Promise<void> {
914
926
  if (this.ctx.session.isStreaming) {
915
927
  this.ctx.showWarning("Wait for the current response to finish or abort it before moving.");
916
928
  return;
917
929
  }
918
930
 
919
- const unquoted = stripOuterDoubleQuotes(targetPath);
931
+ let input: string | undefined = targetPath?.trim() || undefined;
932
+
933
+ // No argument in TUI mode: open the path autocomplete overlay.
934
+ if (!input) {
935
+ const result = await this.ctx.showHookCustom<MoveOverlayResult | undefined>(
936
+ (_tui, _theme, _keybindings, done) => new MoveOverlay(this.ctx.sessionManager.getCwd(), done),
937
+ { overlay: true },
938
+ );
939
+ if (!result) return; // cancelled
940
+ input = result.directory;
941
+ }
942
+
943
+ const unquoted = stripOuterDoubleQuotes(input);
920
944
  if (!unquoted) {
921
945
  this.ctx.showError("Usage: /move <path>");
922
946
  return;
@@ -925,29 +949,85 @@ export class CommandController {
925
949
  const cwd = this.ctx.sessionManager.getCwd();
926
950
  const resolvedPath = resolveToCwd(unquoted, cwd);
927
951
 
952
+ // If the directory doesn't exist, offer to create it.
953
+ let isDirectory: boolean;
928
954
  try {
929
- const stat = await fs.stat(resolvedPath);
930
- if (!stat.isDirectory()) {
931
- this.ctx.showError(`Not a directory: ${resolvedPath}`);
955
+ isDirectory = (await fs.stat(resolvedPath)).isDirectory();
956
+ } catch {
957
+ isDirectory = false;
958
+ }
959
+
960
+ if (!isDirectory) {
961
+ const parentDir = path.dirname(resolvedPath);
962
+ let parentExists = false;
963
+ try {
964
+ parentExists = (await fs.stat(parentDir)).isDirectory();
965
+ } catch {
966
+ parentExists = false;
967
+ }
968
+ if (!parentExists) {
969
+ this.ctx.showError(`Cannot create "${path.basename(resolvedPath)}": parent directory does not exist`);
970
+ return;
971
+ }
972
+ const confirmed = await this.ctx.showHookConfirm(
973
+ "Create directory?",
974
+ `"${path.basename(resolvedPath)}" does not exist. Create it?`,
975
+ );
976
+ if (!confirmed) return;
977
+ try {
978
+ await fs.mkdir(resolvedPath, { recursive: true });
979
+ } catch (err) {
980
+ this.ctx.showError(`Failed to create directory: ${err instanceof Error ? err.message : String(err)}`);
932
981
  return;
933
982
  }
934
- } catch {
935
- this.ctx.showError(`Directory does not exist: ${resolvedPath}`);
936
- return;
937
983
  }
938
984
 
985
+ let newSessionFile: string | undefined;
939
986
  try {
940
- await this.ctx.sessionManager.flush();
941
- await this.ctx.sessionManager.moveTo(resolvedPath);
942
- await this.ctx.applyCwdChange(resolvedPath);
943
-
944
- this.ctx.present([
945
- new Spacer(1),
946
- new Text(`${theme.fg("accent", `${theme.status.success} Session moved to ${resolvedPath}`)}`, 1, 1),
947
- ]);
987
+ // Create a fresh empty session file in the target directory's session
988
+ // folder, then switch to it. The current session is left behind and
989
+ // remains resumable via /resume.
990
+ newSessionFile = SessionManager.createEmptySessionFile(resolvedPath);
991
+ const switched = await this.ctx.session.switchSession(newSessionFile);
992
+ if (!switched) {
993
+ await this.ctx.sessionManager.dropSession(newSessionFile);
994
+ return;
995
+ }
948
996
  } catch (err) {
997
+ if (newSessionFile) {
998
+ try {
999
+ await this.ctx.sessionManager.dropSession(newSessionFile);
1000
+ } catch (dropErr) {
1001
+ this.ctx.showError(
1002
+ `Move failed: ${err instanceof Error ? err.message : String(err)}; failed to remove empty session: ${dropErr instanceof Error ? dropErr.message : String(dropErr)}`,
1003
+ );
1004
+ return;
1005
+ }
1006
+ }
949
1007
  this.ctx.showError(`Move failed: ${err instanceof Error ? err.message : String(err)}`);
1008
+ return;
950
1009
  }
1010
+
1011
+ this.ctx.session.markMovedFromEmptySessionFile(newSessionFile!);
1012
+ await this.ctx.applyCwdChange(resolvedPath);
1013
+
1014
+ this.ctx.chatContainer.clear();
1015
+ this.ctx.pendingMessagesContainer.clear();
1016
+ this.ctx.compactionQueuedMessages = [];
1017
+ this.ctx.streamingComponent = undefined;
1018
+ this.ctx.streamingMessage = undefined;
1019
+ this.ctx.pendingTools.clear();
1020
+ this.ctx.statusLine.invalidate();
1021
+ this.ctx.statusLine.setSessionStartTime(Date.now());
1022
+ this.ctx.updateEditorTopBorder();
1023
+ this.ctx.updateEditorBorderColor();
1024
+ await this.ctx.reloadTodos();
1025
+ this.ctx.ui.requestRender(true, { clearScrollback: true });
1026
+
1027
+ this.ctx.present([
1028
+ new Spacer(1),
1029
+ new Text(`${theme.fg("accent", `${theme.status.success} Moved to ${resolvedPath}`)}`, 1, 1),
1030
+ ]);
951
1031
  }
952
1032
 
953
1033
  async handleRenameCommand(title: string): Promise<void> {
@@ -1306,22 +1386,10 @@ export function renderProviderSection(details: ProviderDetails, uiTheme: Pick<ty
1306
1386
  return `${lines.join("\n")}\n`;
1307
1387
  }
1308
1388
 
1309
- function resolveFraction(limit: UsageLimit): number | undefined {
1310
- const amount = limit.amount;
1311
- if (amount.usedFraction !== undefined) return amount.usedFraction;
1312
- if (amount.used !== undefined && amount.limit !== undefined && amount.limit > 0) {
1313
- return amount.used / amount.limit;
1314
- }
1315
- if (amount.unit === "percent" && amount.used !== undefined) {
1316
- return amount.used / 100;
1317
- }
1318
- return undefined;
1319
- }
1320
-
1321
1389
  function resolveProviderUsageTotal(reports: UsageReport[]): number {
1322
1390
  return reports
1323
1391
  .flatMap(report => report.limits)
1324
- .map(limit => resolveFraction(limit) ?? 0)
1392
+ .map(limit => resolveUsedFraction(limit) ?? 0)
1325
1393
  .reduce((sum, value) => sum + value, 0);
1326
1394
  }
1327
1395
 
@@ -1342,22 +1410,28 @@ function formatWindowSuffix(label: string, windowLabel: string, uiTheme: typeof
1342
1410
  }
1343
1411
 
1344
1412
  function formatAccountLabel(limit: UsageLimit, report: UsageReport, index: number): string {
1345
- const email = (report.metadata?.email as string | undefined) ?? limit.scope.accountId;
1346
- if (email) return email;
1347
- const accountId = (report.metadata?.accountId as string | undefined) ?? limit.scope.accountId;
1413
+ const email = report.metadata?.email;
1414
+ if (typeof email === "string" && email) return email;
1415
+ const accountId =
1416
+ typeof report.metadata?.accountId === "string" && report.metadata.accountId
1417
+ ? report.metadata.accountId
1418
+ : limit.scope.accountId || undefined;
1348
1419
  if (accountId) return accountId;
1349
- const projectId = (report.metadata?.projectId as string | undefined) ?? limit.scope.projectId;
1420
+ const projectId =
1421
+ typeof report.metadata?.projectId === "string" && report.metadata.projectId
1422
+ ? report.metadata.projectId
1423
+ : limit.scope.projectId || undefined;
1350
1424
  if (projectId) return projectId;
1351
1425
  return `account ${index + 1}`;
1352
1426
  }
1353
1427
 
1354
1428
  function formatUnlimitedReportLabel(report: UsageReport, index: number): string {
1355
- const email = report.metadata?.email as string | undefined;
1356
- if (email) return email;
1357
- const accountId = report.metadata?.accountId as string | undefined;
1358
- if (accountId) return accountId;
1359
- const projectId = report.metadata?.projectId as string | undefined;
1360
- if (projectId) return projectId;
1429
+ const email = report.metadata?.email;
1430
+ if (typeof email === "string" && email) return email;
1431
+ const accountId = report.metadata?.accountId;
1432
+ if (typeof accountId === "string" && accountId) return accountId;
1433
+ const projectId = report.metadata?.projectId;
1434
+ if (typeof projectId === "string" && projectId) return projectId;
1361
1435
  return `account ${index + 1}`;
1362
1436
  }
1363
1437
 
@@ -1432,7 +1506,7 @@ function resolveAggregateStatus(limits: UsageLimit[]): UsageLimit["status"] {
1432
1506
 
1433
1507
  function formatAggregateAmount(limits: UsageLimit[]): string {
1434
1508
  const fractions = limits
1435
- .map(limit => resolveFraction(limit))
1509
+ .map(limit => resolveUsedFraction(limit))
1436
1510
  .filter((value): value is number => value !== undefined);
1437
1511
  if (fractions.length === limits.length && fractions.length > 0) {
1438
1512
  const sum = fractions.reduce((total, value) => total + value, 0);
@@ -1489,7 +1563,7 @@ function resolveStatusColor(status: UsageLimit["status"]): "success" | "warning"
1489
1563
  }
1490
1564
 
1491
1565
  function renderUsageBar(limit: UsageLimit, uiTheme: typeof theme, barWidth: number): string {
1492
- const fraction = resolveFraction(limit);
1566
+ const fraction = resolveUsedFraction(limit);
1493
1567
  if (fraction === undefined) {
1494
1568
  return uiTheme.fg("dim", "·".repeat(barWidth));
1495
1569
  }
@@ -1597,9 +1671,11 @@ export function renderUsageReports(
1597
1671
  const count = report.resetCredits?.availableCount ?? 0;
1598
1672
  if (count <= 0) continue;
1599
1673
  const label =
1600
- (report.metadata?.email as string | undefined) ??
1601
- (report.metadata?.accountId as string | undefined) ??
1602
- "account";
1674
+ typeof report.metadata?.email === "string" && report.metadata.email
1675
+ ? report.metadata.email
1676
+ : typeof report.metadata?.accountId === "string" && report.metadata.accountId
1677
+ ? report.metadata.accountId
1678
+ : "account";
1603
1679
  const isActive =
1604
1680
  !!activeAccount &&
1605
1681
  ((!!activeAccount.accountId && activeAccount.accountId === report.metadata?.accountId) ||
@@ -1607,6 +1683,23 @@ export function renderUsageReports(
1607
1683
  resetAccountLines.push(
1608
1684
  ` • ${label}: ${count} saved reset${count === 1 ? "" : "s"}${isActive ? " (active)" : ""}`,
1609
1685
  );
1686
+ const credits = report.resetCredits?.credits;
1687
+ if (credits) {
1688
+ for (const credit of credits) {
1689
+ if (credit.expiresAt) {
1690
+ const expiryMs = Date.parse(credit.expiresAt);
1691
+ if (!Number.isNaN(expiryMs)) {
1692
+ const remaining = expiryMs - nowMs;
1693
+ const expiryDate = credit.expiresAt.slice(0, 10);
1694
+ if (remaining > 0) {
1695
+ resetAccountLines.push(` expires in ${formatDuration(remaining)} (${expiryDate})`);
1696
+ } else {
1697
+ resetAccountLines.push(` expired (${expiryDate})`);
1698
+ }
1699
+ }
1700
+ }
1701
+ }
1702
+ }
1610
1703
  }
1611
1704
  if (resetAccountLines.length > 0) {
1612
1705
  lines.push(
@@ -1619,7 +1712,7 @@ export function renderUsageReports(
1619
1712
  const entries = group.limits.map((limit, index) => ({
1620
1713
  limit,
1621
1714
  report: group.reports[index],
1622
- fraction: resolveFraction(limit),
1715
+ fraction: resolveUsedFraction(limit),
1623
1716
  index,
1624
1717
  }));
1625
1718
  entries.sort((a, b) => {
@@ -1672,8 +1765,8 @@ export function renderUsageReports(
1672
1765
  const unlimitedReports = providerReports.filter(report => report.limits.length === 0);
1673
1766
  for (const report of unlimitedReports) {
1674
1767
  const label = formatUnlimitedReportLabel(report, 0);
1675
- const tier = report.metadata?.planType as string | undefined;
1676
- const tierSuffix = tier ? ` ${uiTheme.fg("dim", `(${tier})`)}` : "";
1768
+ const tier = report.metadata?.planType;
1769
+ const tierSuffix = typeof tier === "string" && tier ? ` ${uiTheme.fg("dim", `(${tier})`)}` : "";
1677
1770
  lines.push(
1678
1771
  `${uiTheme.fg("success", uiTheme.status.success)} ${label}${tierSuffix} ${uiTheme.fg("dim", "-- no limits")}`,
1679
1772
  );
@@ -5,7 +5,7 @@ import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
5
5
  import { extractTextContent } from "../../commit/utils";
6
6
  import { settings } from "../../config/settings";
7
7
  import { getFileSnapshotStore } from "../../edit/file-snapshot-store";
8
- import type { AssistantMessageComponent } from "../../modes/components/assistant-message";
8
+ import { AssistantMessageComponent } from "../../modes/components/assistant-message";
9
9
  import { detectCacheInvalidation } from "../../modes/components/cache-invalidation-marker";
10
10
  import {
11
11
  ReadToolGroupComponent,
@@ -86,12 +86,14 @@ export class EventController {
86
86
  #lastTtsrNotification: TtsrNotificationComponent | undefined = undefined;
87
87
  #streamingReveal: StreamingRevealController;
88
88
  #toolArgsReveal: ToolArgsRevealController;
89
+ #prevHideThinking = false;
89
90
  #handlers: AgentSessionEventHandlers;
91
+ #terminalProgressActive = false;
90
92
 
91
93
  constructor(private ctx: InteractiveModeContext) {
92
94
  this.#streamingReveal = new StreamingRevealController({
93
95
  getSmoothStreaming: () => this.ctx.settings.get("display.smoothStreaming"),
94
- getHideThinkingBlock: () => this.ctx.hideThinkingBlock,
96
+ getHideThinkingBlock: () => this.ctx.effectiveHideThinkingBlock,
95
97
  getProseOnlyThinking: () => this.ctx.proseOnlyThinking,
96
98
  requestRender: () => this.ctx.ui.requestRender(),
97
99
  });
@@ -124,7 +126,27 @@ export class EventController {
124
126
  thinking_level_changed: async () => {
125
127
  this.ctx.statusLine.invalidate();
126
128
  this.ctx.updateEditorBorderColor();
127
- this.ctx.ui.requestRender();
129
+ const hideThinking = this.ctx.effectiveHideThinkingBlock;
130
+ // Only do the expensive full resetDisplay when the effective
131
+ // visibility actually changed. Auto-classification (e.g. high→medium)
132
+ // emits thinking_level_changed without changing visibility — a full
133
+ // terminal replay for those would be disruptive.
134
+ if (hideThinking === this.#prevHideThinking) {
135
+ this.ctx.ui.requestRender();
136
+ return;
137
+ }
138
+ this.#prevHideThinking = hideThinking;
139
+ // Propagate visibility to existing rendered messages.
140
+ for (const child of this.ctx.chatContainer.children) {
141
+ if (child instanceof AssistantMessageComponent) {
142
+ child.setHideThinkingBlock(hideThinking);
143
+ }
144
+ }
145
+ if (this.ctx.streamingComponent && this.ctx.streamingMessage) {
146
+ this.ctx.streamingComponent.setHideThinkingBlock(hideThinking);
147
+ this.#streamingReveal.resyncVisibility();
148
+ }
149
+ this.ctx.ui.resetDisplay();
128
150
  },
129
151
  goal_updated: async () => {},
130
152
  } satisfies AgentSessionEventHandlers;
@@ -134,6 +156,7 @@ export class EventController {
134
156
  this.#streamingReveal.stop();
135
157
  this.#toolArgsReveal.stop();
136
158
  this.#cancelIdleCompaction();
159
+ this.#setTerminalProgress(false);
137
160
  for (const timer of this.#ircExpiryTimers.values()) {
138
161
  clearTimeout(timer);
139
162
  }
@@ -248,6 +271,18 @@ export class EventController {
248
271
  await run(event);
249
272
  }
250
273
 
274
+ #setTerminalProgress(active: boolean): void {
275
+ if (active) {
276
+ if (this.#terminalProgressActive || this.ctx.settings?.get("terminal.showProgress") !== true) return;
277
+ this.ctx.ui.terminal.setProgress(true);
278
+ this.#terminalProgressActive = true;
279
+ return;
280
+ }
281
+ if (!this.#terminalProgressActive) return;
282
+ this.ctx.ui.terminal.setProgress(false);
283
+ this.#terminalProgressActive = false;
284
+ }
285
+
251
286
  async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
252
287
  this.#lastIntent = undefined;
253
288
  this.#readToolCallArgs.clear();
@@ -266,11 +301,13 @@ export class EventController {
266
301
  this.ctx.statusContainer.clear();
267
302
  }
268
303
  this.#cancelIdleCompaction();
304
+ this.#setTerminalProgress(true);
269
305
  this.ctx.ensureLoadingAnimation();
270
306
  this.ctx.ui.requestRender();
271
307
  }
272
308
 
273
309
  async #handleMessageStart(event: Extract<AgentSessionEvent, { type: "message_start" }>): Promise<void> {
310
+ this.#ensureWorkingLoaderWhileStreaming();
274
311
  if (event.message.role === "hookMessage" || event.message.role === "custom") {
275
312
  const signature = `${event.message.role}:${event.message.customType}:${event.message.timestamp}`;
276
313
  if (this.#renderedCustomMessages.has(signature)) {
@@ -513,6 +550,7 @@ export class EventController {
513
550
  }
514
551
 
515
552
  async #handleMessageUpdate(event: Extract<AgentSessionEvent, { type: "message_update" }>): Promise<void> {
553
+ this.#ensureWorkingLoaderWhileStreaming();
516
554
  this.#vocalizeDelta(event);
517
555
  if (this.ctx.streamingComponent && event.message.role === "assistant") {
518
556
  this.ctx.streamingMessage = event.message;
@@ -737,6 +775,7 @@ export class EventController {
737
775
  }
738
776
 
739
777
  async #handleToolExecutionStart(event: Extract<AgentSessionEvent, { type: "tool_execution_start" }>): Promise<void> {
778
+ this.#ensureWorkingLoaderWhileStreaming();
740
779
  this.#updateWorkingMessageFromIntent(event.intent);
741
780
  this.#resolveDisplaceablePoll(event.toolName);
742
781
  if (!this.ctx.pendingTools.has(event.toolCallId)) {
@@ -801,6 +840,7 @@ export class EventController {
801
840
  async #handleToolExecutionUpdate(
802
841
  event: Extract<AgentSessionEvent, { type: "tool_execution_update" }>,
803
842
  ): Promise<void> {
843
+ this.#ensureWorkingLoaderWhileStreaming();
804
844
  const component = this.ctx.pendingTools.get(event.toolCallId);
805
845
  if (component) {
806
846
  const asyncState = (event.partialResult.details as { async?: { state?: string } } | undefined)?.async?.state;
@@ -929,6 +969,7 @@ export class EventController {
929
969
  }
930
970
 
931
971
  async #finishAgentEnd(): Promise<void> {
972
+ this.#setTerminalProgress(false);
932
973
  this.#streamingReveal.stop();
933
974
  this.#toolArgsReveal.flushAll();
934
975
  if (this.ctx.loadingAnimation) {
@@ -989,6 +1030,17 @@ export class EventController {
989
1030
  }
990
1031
  }
991
1032
 
1033
+ /**
1034
+ * Restore the live "Working…" loader when a streaming event lands after a
1035
+ * transient status overlay cleared the container. Focus mode dispatches events
1036
+ * for `viewSession`, so key the reconciler on that session, not the main one.
1037
+ */
1038
+ #ensureWorkingLoaderWhileStreaming(): void {
1039
+ if (!this.ctx.viewSession.isStreaming) return;
1040
+ if (this.ctx.autoCompactionLoader || this.ctx.retryLoader) return;
1041
+ this.ctx.ensureLoadingAnimation();
1042
+ }
1043
+
992
1044
  /**
993
1045
  * Trailing Esc hint for live maintenance loaders. While a subagent is
994
1046
  * focused, Esc returns to main instead of cancelling its maintenance
@@ -1004,6 +1056,7 @@ export class EventController {
1004
1056
  event: Extract<AgentSessionEvent, { type: "auto_compaction_start" }>,
1005
1057
  ): Promise<void> {
1006
1058
  this.#cancelIdleCompaction();
1059
+ this.#setTerminalProgress(true);
1007
1060
  this.#stopWorkingLoader();
1008
1061
  this.ctx.statusContainer.clear();
1009
1062
  const reasonText =
@@ -1035,6 +1088,7 @@ export class EventController {
1035
1088
 
1036
1089
  async #handleAutoCompactionEnd(event: Extract<AgentSessionEvent, { type: "auto_compaction_end" }>): Promise<void> {
1037
1090
  this.#cancelIdleCompaction();
1091
+ this.#setTerminalProgress(false);
1038
1092
  if (this.ctx.autoCompactionLoader) {
1039
1093
  this.ctx.autoCompactionLoader.stop();
1040
1094
  this.ctx.autoCompactionLoader = undefined;
@@ -1096,6 +1150,7 @@ export class EventController {
1096
1150
  this.ctx.showWarning("Auto context-full maintenance failed; continuing without maintenance");
1097
1151
  }
1098
1152
  await this.ctx.flushCompactionQueue({ willRetry: event.willRetry });
1153
+ this.#ensureWorkingLoaderWhileStreaming();
1099
1154
  this.ctx.ui.requestRender();
1100
1155
  }
1101
1156
 
@@ -1130,6 +1185,7 @@ export class EventController {
1130
1185
  if (!event.success) {
1131
1186
  this.ctx.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`);
1132
1187
  }
1188
+ this.#ensureWorkingLoaderWhileStreaming();
1133
1189
  this.ctx.ui.requestRender();
1134
1190
  }
1135
1191
 
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
+ import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
3
4
  import type { ImageContent } from "@oh-my-pi/pi-ai";
4
5
  import { type AutocompleteProvider, matchesKey, type SlashCommand } from "@oh-my-pi/pi-tui";
5
6
  import { $env, isEnoent, logger, sanitizeText } from "@oh-my-pi/pi-utils";
@@ -124,6 +125,7 @@ const TINY_TITLE_PROGRESS_REVEAL_DELAY_MS = 1_000;
124
125
  // deliberate human double-tap is always tens of milliseconds apart.
125
126
  const LEFT_DOUBLE_TAP_MIN_GAP_MS = 40;
126
127
  const LEFT_DOUBLE_TAP_MAX_GAP_MS = 500;
128
+ const STREAMING_ESCAPE_CANCEL_WINDOW_MS = 2_000;
127
129
 
128
130
  export class InputController {
129
131
  constructor(
@@ -148,6 +150,16 @@ export class InputController {
148
150
  // (>= LEFT_DOUBLE_TAP_MAX_GAP_MS) starts a fresh sequence. See
149
151
  // #detectLeftDoubleTap.
150
152
  #leftTapCount = 0;
153
+ // Streaming turns use a two-step Esc: first press arms this token, second press
154
+ // within the window aborts the same live assistant turn. The token is a per-turn
155
+ // sentinel minted lazily on demand and reset on every `agent_start`/`agent_end`
156
+ // (see setupKeyHandlers), so it survives `message_start`/`message_update`
157
+ // transitions inside a single turn but cannot leak across turn boundaries.
158
+ #streamingEscapeTurnSentinel: object | undefined;
159
+ #streamingEscapeArmedToken: object | undefined;
160
+ #streamingEscapeArmedUntil = 0;
161
+ #streamingEscapeTimer: NodeJS.Timeout | undefined;
162
+ #streamingEscapeSessionSubscribed = false;
151
163
  // Sequential index for `local://attachment-N` references created by large-paste and
152
164
  // pasted-file attachments. Seeded from 0 and bumped past existing attachment files.
153
165
  #attachmentCounter = 0;
@@ -197,8 +209,50 @@ export class InputController {
197
209
  const unsubscribe = tinyTitleClient.onProgress(update);
198
210
  }
199
211
 
212
+ #clearStreamingEscapeArm(): void {
213
+ this.#streamingEscapeArmedToken = undefined;
214
+ this.#streamingEscapeArmedUntil = 0;
215
+ if (this.#streamingEscapeTimer) {
216
+ clearTimeout(this.#streamingEscapeTimer);
217
+ this.#streamingEscapeTimer = undefined;
218
+ }
219
+ }
220
+
221
+ #handleStreamingEscape(): void {
222
+ if (!this.#streamingEscapeTurnSentinel) {
223
+ this.#streamingEscapeTurnSentinel = {};
224
+ }
225
+ const token = this.#streamingEscapeTurnSentinel;
226
+ const now = Date.now();
227
+ if (this.#streamingEscapeArmedToken === token && now <= this.#streamingEscapeArmedUntil) {
228
+ this.#clearStreamingEscapeArm();
229
+ void this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL });
230
+ return;
231
+ }
232
+
233
+ this.#clearStreamingEscapeArm();
234
+ this.#streamingEscapeArmedToken = token;
235
+ this.#streamingEscapeArmedUntil = now + STREAMING_ESCAPE_CANCEL_WINDOW_MS;
236
+ this.#streamingEscapeTimer = setTimeout(() => {
237
+ if (this.#streamingEscapeArmedToken === token && Date.now() >= this.#streamingEscapeArmedUntil) {
238
+ this.#clearStreamingEscapeArm();
239
+ }
240
+ }, STREAMING_ESCAPE_CANCEL_WINDOW_MS);
241
+ this.#streamingEscapeTimer.unref?.();
242
+ this.ctx.showStatus("Press Esc again within 2s to cancel streaming.");
243
+ }
244
+
200
245
  setupKeyHandlers(): void {
201
246
  this.ctx.editor.setActionKeys("app.interrupt", this.ctx.keybindings.getKeys("app.interrupt"));
247
+ if (!this.#streamingEscapeSessionSubscribed && typeof this.ctx.session.subscribe === "function") {
248
+ this.#streamingEscapeSessionSubscribed = true;
249
+ this.ctx.session.subscribe(event => {
250
+ if (event.type === "agent_start" || event.type === "agent_end") {
251
+ this.#streamingEscapeTurnSentinel = undefined;
252
+ this.#clearStreamingEscapeArm();
253
+ }
254
+ });
255
+ }
202
256
  if (!this.#focusedLeftTapListenerInstalled) {
203
257
  this.#focusedLeftTapListenerInstalled = true;
204
258
  this.ctx.ui.addInputListener(data => {
@@ -274,7 +328,7 @@ export class InputController {
274
328
  if (this.ctx.loopModeEnabled) {
275
329
  this.ctx.pauseLoop();
276
330
  if (this.ctx.session.isStreaming) {
277
- void this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL });
331
+ this.#handleStreamingEscape();
278
332
  } else {
279
333
  this.ctx.cancelPendingSubmission();
280
334
  }
@@ -325,12 +379,13 @@ export class InputController {
325
379
  this.ctx.isPythonMode = false;
326
380
  this.ctx.updateEditorBorderColor();
327
381
  } else if (this.ctx.session.isStreaming) {
328
- void this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL });
382
+ this.#handleStreamingEscape();
329
383
  } else if (this.ctx.editor.getText().trim()) {
330
384
  // Esc with typed text clears the draft instead of (or before) any double-Esc action
331
385
  this.ctx.editor.setText("");
332
386
  this.ctx.ui.requestRender();
333
387
  this.ctx.lastEscapeTime = 0;
388
+ this.#clearStreamingEscapeArm();
334
389
  } else {
335
390
  // Double-interrupt with empty editor triggers /tree, /branch, or nothing based on setting
336
391
  const action = settings.get("doubleEscapeAction");
@@ -1684,6 +1739,17 @@ export class InputController {
1684
1739
  }
1685
1740
 
1686
1741
  toggleThinkingBlockVisibility(): void {
1742
+ // When thinking is "off", thinking blocks are always hidden (some
1743
+ // providers return them regardless). The toggle is meaningless in
1744
+ // that state — inform the user instead of silently flipping the
1745
+ // persisted value. When thinking is on, the toggle works normally
1746
+ // even if blocks are already hidden (user may want to show them).
1747
+ const thinkingOff =
1748
+ ((this.ctx.viewSession ?? this.ctx.session)?.thinkingLevel ?? ThinkingLevel.Off) === ThinkingLevel.Off;
1749
+ if (thinkingOff) {
1750
+ this.ctx.showStatus("Thinking is off — enable thinking to show blocks");
1751
+ return;
1752
+ }
1687
1753
  this.ctx.hideThinkingBlock = !this.ctx.hideThinkingBlock;
1688
1754
  this.ctx.settings.set("hideThinkingBlock", this.ctx.hideThinkingBlock);
1689
1755
 
@@ -134,6 +134,9 @@ export class SelectorController {
134
134
  availableThinkingLevels: [...this.ctx.session.getAvailableThinkingLevels()],
135
135
  thinkingLevel: this.ctx.session.thinkingLevel,
136
136
  availableThemes,
137
+ providers: [...new Set(this.ctx.session.getAvailableModels().map(model => model.provider))].sort(
138
+ (a, b) => a.localeCompare(b),
139
+ ),
137
140
  cwd: getProjectDir(),
138
141
  model: this.ctx.session.model,
139
142
  imageBudget: this.ctx.ui.imageBudget,
@@ -341,7 +344,7 @@ export class SelectorController {
341
344
  this.ctx.hideThinkingBlock = value as boolean;
342
345
  for (const child of this.ctx.chatContainer.children) {
343
346
  if (child instanceof AssistantMessageComponent) {
344
- child.setHideThinkingBlock(value as boolean);
347
+ child.setHideThinkingBlock(this.ctx.effectiveHideThinkingBlock);
345
348
  }
346
349
  }
347
350
  // Full clear + replay so blocks frozen in committed scrollback on
@@ -1302,7 +1305,7 @@ export class SelectorController {
1302
1305
  getTool: name => this.ctx.session.getToolByName(name),
1303
1306
  getMessageRenderer: type => this.ctx.session.extensionRunner?.getMessageRenderer(type),
1304
1307
  cwd: this.ctx.sessionManager.getCwd(),
1305
- hideThinkingBlock: () => this.ctx.hideThinkingBlock,
1308
+ hideThinkingBlock: () => this.ctx.effectiveHideThinkingBlock,
1306
1309
  proseOnlyThinking: () => this.ctx.proseOnlyThinking,
1307
1310
  focusAgent: id => this.ctx.focusAgentSession(id),
1308
1311
  sessionFile: this.ctx.sessionManager.getSessionFile() ?? null,
@@ -270,6 +270,23 @@ export class StreamingRevealController {
270
270
  this.#unitCounter.reset();
271
271
  }
272
272
 
273
+ /**
274
+ * Re-read cached visibility flags (hideThinkingBlock, proseOnlyThinking)
275
+ * and re-render the current target. Called when the thinking level changes
276
+ * mid-stream so the reveal controller doesn't keep rendering with stale values.
277
+ */
278
+ resyncVisibility(): void {
279
+ if (!this.#target || !this.#component) return;
280
+ this.#hideThinkingBlock = this.#getHideThinkingBlock();
281
+ this.#proseOnlyThinking = this.#getProseOnlyThinking();
282
+ // Recalculate visible units — hiding thinking blocks may reduce the total,
283
+ // and the reveal position may now exceed it.
284
+ const total = this.#visibleUnits(this.#target);
285
+ this.#revealed = Math.min(this.#revealed, total);
286
+ this.#renderCurrent();
287
+ this.#syncTimer(total);
288
+ }
289
+
273
290
  /** Total reveal units of `message`, memoized per block across ticks. */
274
291
  #visibleUnits(message: AssistantMessage): number {
275
292
  let total = 0;
@@ -1,4 +1,4 @@
1
- import { parseStreamingJson } from "@oh-my-pi/pi-ai/utils/json-parse";
1
+ import { parseStreamingJson } from "@oh-my-pi/pi-utils";
2
2
  import { nextStep, STREAMING_REVEAL_FRAME_MS } from "./streaming-reveal";
3
3
 
4
4
  /** Minimal component surface the reveal pushes frames into. */