@oh-my-pi/pi-coding-agent 15.7.5 → 15.8.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 (146) hide show
  1. package/CHANGELOG.md +159 -182
  2. package/dist/types/async/job-manager.d.ts +3 -3
  3. package/dist/types/cli/args.d.ts +1 -0
  4. package/dist/types/cli/claude-trace-cli.d.ts +54 -0
  5. package/dist/types/cli/session-picker.d.ts +10 -3
  6. package/dist/types/cli/update-cli.d.ts +17 -0
  7. package/dist/types/commands/launch.d.ts +3 -0
  8. package/dist/types/config/keybindings.d.ts +5 -0
  9. package/dist/types/config/settings-schema.d.ts +11 -2
  10. package/dist/types/config/settings.d.ts +13 -0
  11. package/dist/types/eval/__tests__/heartbeat.test.d.ts +1 -0
  12. package/dist/types/eval/concurrency-bridge.d.ts +26 -0
  13. package/dist/types/eval/heartbeat.d.ts +45 -0
  14. package/dist/types/eval/js/tool-bridge.d.ts +2 -1
  15. package/dist/types/extensibility/extensions/loader.d.ts +2 -2
  16. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  17. package/dist/types/extensibility/extensions/types.d.ts +24 -5
  18. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +11 -0
  19. package/dist/types/lsp/diagnostics-ledger.d.ts +10 -0
  20. package/dist/types/lsp/index.d.ts +2 -0
  21. package/dist/types/lsp/utils.d.ts +4 -0
  22. package/dist/types/main.d.ts +5 -0
  23. package/dist/types/modes/acp/acp-agent.d.ts +1 -1
  24. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  25. package/dist/types/modes/components/custom-editor.d.ts +3 -2
  26. package/dist/types/modes/components/hook-selector.d.ts +10 -1
  27. package/dist/types/modes/components/session-selector.d.ts +32 -5
  28. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  29. package/dist/types/modes/controllers/extension-ui-controller.d.ts +3 -3
  30. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  31. package/dist/types/modes/interactive-mode.d.ts +10 -3
  32. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  33. package/dist/types/modes/theme/theme.d.ts +1 -1
  34. package/dist/types/modes/types.d.ts +5 -3
  35. package/dist/types/registry/agent-registry.d.ts +1 -1
  36. package/dist/types/sdk.d.ts +2 -2
  37. package/dist/types/session/agent-session.d.ts +12 -3
  38. package/dist/types/session/history-storage.d.ts +16 -1
  39. package/dist/types/session/session-manager.d.ts +4 -0
  40. package/dist/types/task/output-manager.d.ts +6 -15
  41. package/dist/types/tools/ask.d.ts +8 -6
  42. package/dist/types/tools/eval-backends.d.ts +12 -0
  43. package/dist/types/tools/eval-render.d.ts +52 -0
  44. package/dist/types/tools/eval.d.ts +2 -35
  45. package/dist/types/tools/find.d.ts +0 -9
  46. package/dist/types/tools/index.d.ts +5 -12
  47. package/dist/types/tools/path-utils.d.ts +16 -0
  48. package/dist/types/tools/sqlite-reader.d.ts +25 -8
  49. package/dist/types/tui/output-block.d.ts +4 -3
  50. package/dist/types/utils/clipboard.d.ts +4 -0
  51. package/dist/types/web/kagi.d.ts +76 -0
  52. package/dist/types/web/search/providers/exa.d.ts +7 -1
  53. package/dist/types/web/search/providers/kagi.d.ts +1 -0
  54. package/examples/extensions/README.md +1 -0
  55. package/examples/extensions/thinking-note.ts +13 -0
  56. package/package.json +9 -9
  57. package/src/async/job-manager.ts +3 -3
  58. package/src/cli/args.ts +6 -2
  59. package/src/cli/claude-trace-cli.ts +783 -0
  60. package/src/cli/session-picker.ts +36 -10
  61. package/src/cli/update-cli.ts +35 -2
  62. package/src/commands/launch.ts +3 -0
  63. package/src/config/keybindings.ts +6 -0
  64. package/src/config/model-registry.ts +33 -4
  65. package/src/config/settings-schema.ts +12 -2
  66. package/src/config/settings.ts +23 -0
  67. package/src/discovery/claude-plugins.ts +7 -9
  68. package/src/discovery/claude.ts +41 -22
  69. package/src/edit/index.ts +23 -3
  70. package/src/eval/__tests__/agent-bridge.test.ts +148 -4
  71. package/src/eval/__tests__/heartbeat.test.ts +84 -0
  72. package/src/eval/__tests__/llm-bridge.test.ts +30 -0
  73. package/src/eval/agent-bridge.ts +44 -38
  74. package/src/eval/concurrency-bridge.ts +34 -0
  75. package/src/eval/heartbeat.ts +74 -0
  76. package/src/eval/js/executor.ts +13 -9
  77. package/src/eval/js/shared/prelude.txt +20 -17
  78. package/src/eval/js/tool-bridge.ts +5 -0
  79. package/src/eval/llm-bridge.ts +20 -14
  80. package/src/eval/py/executor.ts +14 -18
  81. package/src/eval/py/prelude.py +23 -15
  82. package/src/exec/bash-executor.ts +31 -5
  83. package/src/extensibility/extensions/loader.ts +16 -18
  84. package/src/extensibility/extensions/runner.ts +22 -17
  85. package/src/extensibility/extensions/types.ts +39 -5
  86. package/src/extensibility/plugins/legacy-pi-compat.ts +115 -131
  87. package/src/extensibility/skills.ts +0 -1
  88. package/src/internal-urls/docs-index.generated.ts +14 -13
  89. package/src/lsp/diagnostics-ledger.ts +51 -0
  90. package/src/lsp/index.ts +9 -22
  91. package/src/lsp/utils.ts +21 -0
  92. package/src/main.ts +92 -24
  93. package/src/modes/acp/acp-agent.ts +8 -4
  94. package/src/modes/acp/acp-event-mapper.ts +54 -4
  95. package/src/modes/components/assistant-message.ts +28 -1
  96. package/src/modes/components/custom-editor.ts +19 -7
  97. package/src/modes/components/hook-selector.ts +229 -44
  98. package/src/modes/components/oauth-selector.ts +12 -6
  99. package/src/modes/components/session-selector.ts +179 -24
  100. package/src/modes/components/tool-execution.ts +36 -7
  101. package/src/modes/controllers/command-controller.ts +2 -11
  102. package/src/modes/controllers/event-controller.ts +5 -2
  103. package/src/modes/controllers/extension-ui-controller.ts +6 -4
  104. package/src/modes/controllers/input-controller.ts +19 -16
  105. package/src/modes/controllers/selector-controller.ts +61 -21
  106. package/src/modes/interactive-mode.ts +127 -16
  107. package/src/modes/rpc/rpc-mode.ts +17 -6
  108. package/src/modes/theme/theme-schema.json +30 -0
  109. package/src/modes/theme/theme.ts +39 -2
  110. package/src/modes/types.ts +7 -3
  111. package/src/modes/utils/ui-helpers.ts +5 -2
  112. package/src/prompts/system/orchestrate-notice.md +5 -3
  113. package/src/prompts/system/workflow-notice.md +2 -2
  114. package/src/prompts/tools/ask.md +2 -1
  115. package/src/prompts/tools/eval.md +6 -6
  116. package/src/prompts/tools/find.md +1 -1
  117. package/src/prompts/tools/irc.md +6 -6
  118. package/src/prompts/tools/search.md +1 -1
  119. package/src/prompts/tools/task.md +1 -1
  120. package/src/registry/agent-registry.ts +1 -1
  121. package/src/sdk.ts +85 -31
  122. package/src/session/agent-session.ts +127 -57
  123. package/src/session/history-storage.ts +56 -12
  124. package/src/session/session-manager.ts +34 -0
  125. package/src/task/output-manager.ts +40 -48
  126. package/src/task/render.ts +3 -8
  127. package/src/tools/ask.ts +74 -32
  128. package/src/tools/browser/tab-worker.ts +8 -5
  129. package/src/tools/eval-backends.ts +38 -0
  130. package/src/tools/eval-render.ts +750 -0
  131. package/src/tools/eval.ts +27 -754
  132. package/src/tools/find.ts +5 -29
  133. package/src/tools/index.ts +8 -38
  134. package/src/tools/path-utils.ts +144 -1
  135. package/src/tools/read.ts +47 -0
  136. package/src/tools/renderers.ts +1 -1
  137. package/src/tools/search.ts +2 -27
  138. package/src/tools/sqlite-reader.ts +92 -9
  139. package/src/tools/write.ts +9 -1
  140. package/src/tui/output-block.ts +5 -4
  141. package/src/utils/clipboard.ts +38 -1
  142. package/src/utils/open.ts +37 -2
  143. package/src/web/kagi.ts +168 -49
  144. package/src/web/search/providers/anthropic.ts +1 -1
  145. package/src/web/search/providers/exa.ts +20 -86
  146. package/src/web/search/providers/kagi.ts +4 -0
@@ -3,7 +3,7 @@ import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth";
3
3
  import type { OAuthProvider } from "@oh-my-pi/pi-ai/utils/oauth/types";
4
4
  import type { Component, OverlayHandle } from "@oh-my-pi/pi-tui";
5
5
  import { Input, Loader, Spacer, Text } from "@oh-my-pi/pi-tui";
6
- import { getAgentDbPath, getProjectDir } from "@oh-my-pi/pi-utils";
6
+ import { getAgentDbPath, getProjectDir, normalizePathForComparison } from "@oh-my-pi/pi-utils";
7
7
  import { getRoleInfo } from "../../config/model-registry";
8
8
  import { formatModelSelectorValue } from "../../config/model-resolver";
9
9
  import { settings } from "../../config/settings";
@@ -36,6 +36,7 @@ import {
36
36
  setPreferredImageProvider,
37
37
  setPreferredSearchProvider,
38
38
  } from "../../tools";
39
+ import { shortenPath } from "../../tools/render-utils";
39
40
  import { setSessionTerminalTitle } from "../../utils/title-generator";
40
41
  import { AgentDashboard } from "../components/agent-dashboard";
41
42
  import { AssistantMessageComponent } from "../components/assistant-message";
@@ -719,12 +720,22 @@ export class SelectorController {
719
720
  this.ctx.sessionManager.getCwd(),
720
721
  this.ctx.sessionManager.getSessionDir(),
721
722
  );
723
+ // Current folder has no sessions: preload the global list so the picker
724
+ // can open straight into all-projects scope instead of dead-ending.
725
+ let allSessions: SessionInfo[] | undefined;
726
+ let startInAllScope = false;
727
+ if (sessions.length === 0) {
728
+ allSessions = await SessionManager.listAll();
729
+ startInAllScope = allSessions.length > 0;
730
+ }
731
+ const historyStorage = this.ctx.historyStorage;
732
+ const historyMatcher = historyStorage ? (query: string) => historyStorage.matchingSessionIds(query) : undefined;
722
733
  this.showSelector(done => {
723
734
  const selector = new SessionSelectorComponent(
724
735
  sessions,
725
- async sessionPath => {
736
+ async (session: SessionInfo) => {
726
737
  done();
727
- await this.handleResumeSession(sessionPath);
738
+ await this.handleResumeSession(session.path);
728
739
  },
729
740
  () => {
730
741
  done();
@@ -733,19 +744,25 @@ export class SelectorController {
733
744
  () => {
734
745
  void this.ctx.shutdown();
735
746
  },
736
- async (session: SessionInfo) => {
737
- if (!(await this.#detachActiveSessionBeforeDeletion(session.path))) {
738
- return false;
739
- }
740
- const storage = new FileSessionStorage();
741
- try {
742
- await storage.deleteSessionWithArtifacts(session.path);
743
- return true;
744
- } catch (err) {
745
- throw new Error(`Failed to delete session: ${err instanceof Error ? err.message : String(err)}`, {
746
- cause: err,
747
- });
748
- }
747
+ {
748
+ onDelete: async (session: SessionInfo) => {
749
+ if (!(await this.#detachActiveSessionBeforeDeletion(session.path))) {
750
+ return false;
751
+ }
752
+ const storage = new FileSessionStorage();
753
+ try {
754
+ await storage.deleteSessionWithArtifacts(session.path);
755
+ return true;
756
+ } catch (err) {
757
+ throw new Error(`Failed to delete session: ${err instanceof Error ? err.message : String(err)}`, {
758
+ cause: err,
759
+ });
760
+ }
761
+ },
762
+ historyMatcher,
763
+ loadAllSessions: () => SessionManager.listAll(),
764
+ allSessions,
765
+ startInAllScope,
749
766
  },
750
767
  );
751
768
  selector.setOnRequestRender(() => this.ctx.ui.requestRender());
@@ -801,8 +818,17 @@ export class SelectorController {
801
818
  async handleResumeSession(sessionPath: string): Promise<void> {
802
819
  this.#clearTransientSessionUi();
803
820
 
804
- // Switch session via AgentSession (emits hook and tool session events)
821
+ const previousCwd = this.ctx.sessionManager.getCwd();
822
+ // Switch session via AgentSession (emits hook and tool session events). The
823
+ // SessionManager adopts the resumed session's own cwd when it differs.
805
824
  await this.ctx.session.switchSession(sessionPath);
825
+ const newCwd = this.ctx.sessionManager.getCwd();
826
+ const movedProject = normalizePathForComparison(newCwd) !== normalizePathForComparison(previousCwd);
827
+ if (movedProject) {
828
+ // Resumed a session from another project: re-point the process and every
829
+ // cwd-derived cache at it before rendering.
830
+ await this.ctx.applyCwdChange(newCwd);
831
+ }
806
832
  this.#refreshSessionTerminalTitle();
807
833
  this.ctx.updateEditorBorderColor();
808
834
 
@@ -810,7 +836,7 @@ export class SelectorController {
810
836
  this.ctx.chatContainer.clear();
811
837
  this.ctx.renderInitialMessages(undefined, { clearTerminalHistory: true });
812
838
  await this.ctx.reloadTodos();
813
- this.ctx.showStatus("Resumed session");
839
+ this.ctx.showStatus(movedProject ? `Resumed session in ${shortenPath(newCwd)}` : "Resumed session");
814
840
  }
815
841
 
816
842
  async handleSessionDeleteCommand(): Promise<void> {
@@ -919,7 +945,15 @@ export class SelectorController {
919
945
 
920
946
  async #handleOAuthLogout(providerId: string): Promise<void> {
921
947
  try {
922
- await this.ctx.session.modelRegistry.authStorage.logout(providerId);
948
+ const authStorage = this.ctx.session.modelRegistry.authStorage;
949
+ if (!authStorage.has(providerId)) {
950
+ const source = authStorage.describeCredentialSource(providerId, this.ctx.session.sessionId);
951
+ const suffix = source ? ` Current auth comes from ${source}; remove that source to log out.` : "";
952
+ this.ctx.showError(`Logout skipped: no stored credentials for ${providerId}.${suffix}`);
953
+ return;
954
+ }
955
+
956
+ await authStorage.logout(providerId);
923
957
  await this.ctx.session.modelRegistry.refresh();
924
958
  this.ctx.chatContainer.addChild(new Spacer(1));
925
959
  this.ctx.chatContainer.addChild(
@@ -928,6 +962,12 @@ export class SelectorController {
928
962
  this.ctx.chatContainer.addChild(
929
963
  new Text(theme.fg("dim", `Credentials removed from ${getAgentDbPath()}`), 1, 0),
930
964
  );
965
+ const remainingSource = authStorage.describeCredentialSource(providerId, this.ctx.session.sessionId);
966
+ if (remainingSource) {
967
+ this.ctx.chatContainer.addChild(
968
+ new Text(theme.fg("warning", `${providerId} is still authenticated via ${remainingSource}`), 1, 0),
969
+ );
970
+ }
931
971
  this.ctx.ui.requestRender();
932
972
  } catch (error: unknown) {
933
973
  this.ctx.showError(`Logout failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -948,10 +988,10 @@ export class SelectorController {
948
988
  await this.#refreshOAuthProviderAuthState();
949
989
  const oauthProviders = getOAuthProviders();
950
990
  const loggedInProviders = oauthProviders.filter(provider =>
951
- this.ctx.session.modelRegistry.authStorage.hasAuth(provider.id),
991
+ this.ctx.session.modelRegistry.authStorage.has(provider.id),
952
992
  );
953
993
  if (loggedInProviders.length === 0) {
954
- this.ctx.showStatus("No OAuth providers logged in. Use /login first.");
994
+ this.ctx.showStatus("No stored provider credentials to log out. Remove env or config auth at its source.");
955
995
  return;
956
996
  }
957
997
  }
@@ -32,14 +32,29 @@ import {
32
32
  TUI,
33
33
  visibleWidth,
34
34
  } from "@oh-my-pi/pi-tui";
35
- import { APP_NAME, adjustHsv, getProjectDir, hsvToRgb, isEnoent, logger, postmortem, prompt } from "@oh-my-pi/pi-utils";
35
+ import {
36
+ APP_NAME,
37
+ adjustHsv,
38
+ formatNumber,
39
+ getProjectDir,
40
+ hsvToRgb,
41
+ isEnoent,
42
+ logger,
43
+ postmortem,
44
+ prompt,
45
+ setProjectDir,
46
+ } from "@oh-my-pi/pi-utils";
36
47
  import chalk from "chalk";
48
+ import { reset as resetCapabilities } from "../capability";
37
49
  import { KeybindingsManager } from "../config/keybindings";
38
50
  import { MODEL_ROLES, type ModelRole } from "../config/model-registry";
39
51
  import { isSettingsInitialized, Settings, settings } from "../config/settings";
52
+ import { clearClaudePluginRootsCache } from "../discovery/helpers";
40
53
  import type {
54
+ ContextUsage,
41
55
  ExtensionUIContext,
42
56
  ExtensionUIDialogOptions,
57
+ ExtensionUISelectItem,
43
58
  ExtensionWidgetContent,
44
59
  ExtensionWidgetOptions,
45
60
  } from "../extensibility/extensions";
@@ -122,6 +137,7 @@ import type {
122
137
  CompactionQueuedMessage,
123
138
  InteractiveModeContext,
124
139
  InteractiveModeInitOptions,
140
+ InteractiveSelectorDialogOptions,
125
141
  SubmittedUserInput,
126
142
  TodoItem,
127
143
  TodoPhase,
@@ -197,6 +213,8 @@ function formatHudNoteMarker(count: number): string {
197
213
  type GoalSubcommand = "set" | "show" | "pause" | "resume" | "drop" | "budget";
198
214
 
199
215
  const GOAL_SUBCOMMANDS = new Set<GoalSubcommand>(["set", "show", "pause", "resume", "drop", "budget"]);
216
+ const PLAN_KEEP_CONTEXT_OPTION_INDEX = 2;
217
+ const PLAN_KEEP_CONTEXT_DISABLE_THRESHOLD_PERCENT = 95;
200
218
 
201
219
  function parseGoalSubcommand(args: string): { sub: GoalSubcommand | undefined; rest: string } {
202
220
  const trimmed = args.trim();
@@ -210,6 +228,10 @@ function parseGoalSubcommand(args: string): { sub: GoalSubcommand | undefined; r
210
228
  return { sub: undefined, rest: trimmed };
211
229
  }
212
230
 
231
+ function formatContextTokenCount(value: number): string {
232
+ return formatNumber(Math.max(0, Math.round(value))).toLowerCase();
233
+ }
234
+
213
235
  /** Options for creating an InteractiveMode instance (for future API use) */
214
236
  export interface InteractiveModeOptions {
215
237
  /** Providers that were migrated during startup */
@@ -259,6 +281,7 @@ export class InteractiveMode implements InteractiveModeContext {
259
281
  loopPrompt: string | undefined = undefined;
260
282
  loopLimit: LoopLimitRuntime | undefined = undefined;
261
283
  #loopAutoSubmitTimer: NodeJS.Timeout | undefined;
284
+ #todoAutoClearTimer: NodeJS.Timeout | undefined;
262
285
  todoPhases: TodoPhase[] = [];
263
286
  hideThinkingBlock = false;
264
287
  pendingImages: ImageContent[] = [];
@@ -391,6 +414,7 @@ export class InteractiveMode implements InteractiveModeContext {
391
414
  try {
392
415
  this.historyStorage = HistoryStorage.open();
393
416
  this.editor.setHistoryStorage(this.historyStorage);
417
+ this.historyStorage.setSessionResolver(() => this.sessionManager.getSessionId());
394
418
  } catch (error) {
395
419
  logger.warn("History storage unavailable", { error: String(error) });
396
420
  }
@@ -554,6 +578,7 @@ export class InteractiveMode implements InteractiveModeContext {
554
578
  // subagent is doing the work for a still-pending todo) updates as
555
579
  // subagents start, finish, or fail.
556
580
  this.#reconcileTodosWithSubagents();
581
+ this.#syncTodoAutoClearTimer();
557
582
  this.#renderTodoList();
558
583
  this.ui.requestRender();
559
584
  });
@@ -640,6 +665,31 @@ export class InteractiveMode implements InteractiveModeContext {
640
665
  this.session.setSlashCommands(fileCommands);
641
666
  }
642
667
 
668
+ /**
669
+ * Re-point the process and every cwd-derived cache at `newCwd` after the
670
+ * active session's working directory changed (`/move` relocation or resuming
671
+ * a session from another project). The SessionManager's cwd MUST already
672
+ * reflect `newCwd` before this is called.
673
+ */
674
+ async applyCwdChange(newCwd: string): Promise<void> {
675
+ setProjectDir(newCwd);
676
+ // Re-scope project settings (`.claude/settings.yml` etc.) to the new
677
+ // directory in place so the active session and every settings reader pick
678
+ // up the destination project's configuration.
679
+ if (isSettingsInitialized()) {
680
+ await settings.reloadForCwd(newCwd);
681
+ }
682
+ // Re-warm plugin roots, capabilities, slash commands, and the ssh tool so
683
+ // the next prompt sees everything scoped to the new project directory.
684
+ clearClaudePluginRootsCache();
685
+ resetCapabilities();
686
+ await this.refreshSlashCommandState(newCwd);
687
+ await this.session.refreshSshTool({ activateIfAvailable: true });
688
+ setSessionTerminalTitle(this.sessionManager.getSessionName(), this.sessionManager.getCwd());
689
+ this.statusLine.invalidate();
690
+ this.updateEditorTopBorder();
691
+ }
692
+
643
693
  async getUserInput(): Promise<SubmittedUserInput> {
644
694
  if (this.session.getGoalModeState()?.mode === "exiting") {
645
695
  await this.#exitGoalMode({ reason: "completed", silent: true });
@@ -1042,6 +1092,47 @@ export class InteractiveMode implements InteractiveModeContext {
1042
1092
  this.session.setTodoPhases(next);
1043
1093
  }
1044
1094
 
1095
+ #cancelTodoAutoClearTimer(): void {
1096
+ if (!this.#todoAutoClearTimer) return;
1097
+ clearTimeout(this.#todoAutoClearTimer);
1098
+ this.#todoAutoClearTimer = undefined;
1099
+ }
1100
+
1101
+ #isClosedTodo(task: TodoItem): boolean {
1102
+ return task.status === "completed" || task.status === "abandoned";
1103
+ }
1104
+
1105
+ #hasClosedTodos(phases: TodoPhase[]): boolean {
1106
+ return phases.some(phase => phase.tasks.some(task => this.#isClosedTodo(task)));
1107
+ }
1108
+
1109
+ #removeClosedTodos(phases: TodoPhase[]): TodoPhase[] {
1110
+ const next: TodoPhase[] = [];
1111
+ for (const phase of phases) {
1112
+ const tasks = phase.tasks.filter(task => !this.#isClosedTodo(task));
1113
+ if (tasks.length > 0) next.push({ name: phase.name, tasks });
1114
+ }
1115
+ return next;
1116
+ }
1117
+
1118
+ #syncTodoAutoClearTimer(): void {
1119
+ this.#cancelTodoAutoClearTimer();
1120
+ const delaySeconds = this.settings.get("tasks.todoClearDelay");
1121
+ if (!Number.isFinite(delaySeconds) || delaySeconds < 0 || !this.#hasClosedTodos(this.todoPhases)) return;
1122
+ if (delaySeconds === 0) {
1123
+ this.todoPhases = this.#removeClosedTodos(this.todoPhases);
1124
+ return;
1125
+ }
1126
+
1127
+ this.#todoAutoClearTimer = setTimeout(() => {
1128
+ this.#todoAutoClearTimer = undefined;
1129
+ this.todoPhases = this.#removeClosedTodos(this.todoPhases);
1130
+ this.#renderTodoList();
1131
+ this.ui.requestRender();
1132
+ }, delaySeconds * 1000);
1133
+ this.#todoAutoClearTimer.unref?.();
1134
+ }
1135
+
1045
1136
  #getActivePhase(phases: TodoPhase[]): TodoPhase | undefined {
1046
1137
  const nonEmpty = phases.filter(phase => phase.tasks.length > 0);
1047
1138
  const active = nonEmpty.find(phase =>
@@ -1097,6 +1188,7 @@ export class InteractiveMode implements InteractiveModeContext {
1097
1188
 
1098
1189
  async #loadTodoList(): Promise<void> {
1099
1190
  this.todoPhases = this.session.getTodoPhases();
1191
+ this.#syncTodoAutoClearTimer();
1100
1192
  this.#renderTodoList();
1101
1193
  }
1102
1194
 
@@ -1568,6 +1660,28 @@ export class InteractiveMode implements InteractiveModeContext {
1568
1660
  return `up/down navigate enter select ${externalEditorKey.toLowerCase()} open in editor esc cancel`;
1569
1661
  }
1570
1662
 
1663
+ #getPlanApprovalContextUsage(): ContextUsage | undefined {
1664
+ const executionModel = this.#planModePreviousModelState?.model ?? this.session.model;
1665
+ const contextWindow = executionModel?.contextWindow;
1666
+ if (typeof contextWindow === "number") {
1667
+ return this.session.getContextUsage({ contextWindow });
1668
+ }
1669
+ return this.session.getContextUsage();
1670
+ }
1671
+
1672
+ #formatKeepContextLabel(contextUsage: ContextUsage | undefined): string {
1673
+ if (contextUsage?.tokens == null) {
1674
+ return "Approve and keep context";
1675
+ }
1676
+ const tokens = formatContextTokenCount(contextUsage.tokens);
1677
+ const contextWindow = formatContextTokenCount(contextUsage.contextWindow);
1678
+ return `Approve and keep context (~${tokens} / ${contextWindow})`;
1679
+ }
1680
+
1681
+ #isKeepContextDisabled(contextUsage: ContextUsage | undefined): boolean {
1682
+ return contextUsage?.percent != null && contextUsage.percent > PLAN_KEEP_CONTEXT_DISABLE_THRESHOLD_PERCENT;
1683
+ }
1684
+
1571
1685
  async #openPlanInExternalEditor(planFilePath: string): Promise<void> {
1572
1686
  const editorCmd = getEditorCommand();
1573
1687
  if (!editorCmd) {
@@ -2034,11 +2148,9 @@ export class InteractiveMode implements InteractiveModeContext {
2034
2148
  }
2035
2149
 
2036
2150
  this.#renderPlanPreview(planContent, { append: true });
2037
- const contextUsage = this.session.getContextUsage();
2038
- const keepContextLabel =
2039
- contextUsage?.percent != null
2040
- ? `Approve and keep context (${contextUsage.percent.toFixed(1)}%)`
2041
- : "Approve and keep context";
2151
+ const contextUsage = this.#getPlanApprovalContextUsage();
2152
+ const keepContextLabel = this.#formatKeepContextLabel(contextUsage);
2153
+ const keepContextDisabled = this.#isKeepContextDisabled(contextUsage);
2042
2154
 
2043
2155
  // Model-tier slider: let the operator pick which configured role model
2044
2156
  // (smol/default/slow/…) executes the approved plan. The slider always starts
@@ -2073,6 +2185,7 @@ export class InteractiveMode implements InteractiveModeContext {
2073
2185
  {
2074
2186
  helpText,
2075
2187
  onExternalEditor: () => void this.#openPlanInExternalEditor(planFilePath),
2188
+ disabledIndices: keepContextDisabled ? [PLAN_KEEP_CONTEXT_OPTION_INDEX] : undefined,
2076
2189
  },
2077
2190
  { slider },
2078
2191
  );
@@ -2175,6 +2288,7 @@ export class InteractiveMode implements InteractiveModeContext {
2175
2288
  this.loadingAnimation = undefined;
2176
2289
  }
2177
2290
  this.#cleanupMicAnimation();
2291
+ this.#cancelTodoAutoClearTimer();
2178
2292
  this.#cancelGoalContinuation();
2179
2293
  if (this.#sttController) {
2180
2294
  this.#sttController.dispose();
@@ -2230,14 +2344,10 @@ export class InteractiveMode implements InteractiveModeContext {
2230
2344
  // Emit shutdown event to hooks
2231
2345
  await this.session.dispose();
2232
2346
 
2233
- if (this.isInitialized) {
2234
- this.ui.requestRender(true);
2235
- }
2236
-
2237
- // Wait for any pending renders to complete
2238
- // requestRender() uses process.nextTick(), so we wait one tick
2239
- await new Promise(resolve => process.nextTick(resolve));
2240
-
2347
+ // Do not force a final render during teardown: disposed session/UI state can
2348
+ // collapse to an empty frame, clearing the viewport and leaving the parent
2349
+ // shell prompt at row 0. Stop from the last committed frame so the terminal
2350
+ // hands Bash the cursor immediately after visible OMP content.
2241
2351
  // Drain any in-flight Kitty key release events before stopping.
2242
2352
  // This prevents escape sequences from leaking to the parent shell over slow SSH.
2243
2353
  await this.ui.terminal.drainInput(1000);
@@ -2857,6 +2967,7 @@ export class InteractiveMode implements InteractiveModeContext {
2857
2967
  },
2858
2968
  ];
2859
2969
  }
2970
+ this.#syncTodoAutoClearTimer();
2860
2971
  this.#renderTodoList();
2861
2972
  this.ui.requestRender();
2862
2973
  }
@@ -2896,8 +3007,8 @@ export class InteractiveMode implements InteractiveModeContext {
2896
3007
 
2897
3008
  showHookSelector(
2898
3009
  title: string,
2899
- options: string[],
2900
- dialogOptions?: ExtensionUIDialogOptions,
3010
+ options: ExtensionUISelectItem[],
3011
+ dialogOptions?: InteractiveSelectorDialogOptions,
2901
3012
  extra?: { slider?: HookSelectorSlider },
2902
3013
  ): Promise<string | undefined> {
2903
3014
  return this.#extensionUiController.showHookSelector(title, options, dialogOptions, extra);
@@ -12,10 +12,12 @@
12
12
  */
13
13
  import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth";
14
14
  import { $env, readJsonl, Snowflake } from "@oh-my-pi/pi-utils";
15
- import type {
16
- ExtensionUIContext,
17
- ExtensionUIDialogOptions,
18
- ExtensionWidgetOptions,
15
+ import {
16
+ type ExtensionUIContext,
17
+ type ExtensionUIDialogOptions,
18
+ type ExtensionUISelectItem,
19
+ type ExtensionWidgetOptions,
20
+ getExtensionUISelectOptionLabel,
19
21
  } from "../../extensibility/extensions";
20
22
  import { type Theme, theme } from "../../modes/theme/theme";
21
23
  import type { AgentSession } from "../../session/agent-session";
@@ -256,11 +258,20 @@ export async function runRpcMode(
256
258
  return promise;
257
259
  }
258
260
 
259
- select(title: string, options: string[], dialogOptions?: ExtensionUIDialogOptions): Promise<string | undefined> {
261
+ select(
262
+ title: string,
263
+ options: ExtensionUISelectItem[],
264
+ dialogOptions?: ExtensionUIDialogOptions,
265
+ ): Promise<string | undefined> {
260
266
  return this.#createDialogPromise(
261
267
  dialogOptions,
262
268
  undefined,
263
- { method: "select", title, options, timeout: dialogOptions?.timeout },
269
+ {
270
+ method: "select",
271
+ title,
272
+ options: options.map(getExtensionUISelectOptionLabel),
273
+ timeout: dialogOptions?.timeout,
274
+ },
264
275
  response => parseValueDialogResponse(response, dialogOptions),
265
276
  );
266
277
  }
@@ -404,6 +404,36 @@
404
404
  "additionalProperties": {
405
405
  "type": "string"
406
406
  }
407
+ },
408
+ "spinnerFrames": {
409
+ "description": "Override the spinner frames. Use a flat array to set both `status` and `activity`, or an object to override each independently. Frames are advanced ~12.5fps for status spinners and ~60fps for activity spinners.",
410
+ "oneOf": [
411
+ {
412
+ "type": "array",
413
+ "minItems": 1,
414
+ "items": { "type": "string", "minLength": 1 }
415
+ },
416
+ {
417
+ "type": "object",
418
+ "properties": {
419
+ "status": {
420
+ "type": "array",
421
+ "minItems": 1,
422
+ "items": { "type": "string", "minLength": 1 }
423
+ },
424
+ "activity": {
425
+ "type": "array",
426
+ "minItems": 1,
427
+ "items": { "type": "string", "minLength": 1 }
428
+ }
429
+ },
430
+ "additionalProperties": false,
431
+ "anyOf": [
432
+ { "required": ["status"] },
433
+ { "required": ["activity"] }
434
+ ]
435
+ }
436
+ ]
407
437
  }
408
438
  },
409
439
  "additionalProperties": false
@@ -812,6 +812,25 @@ const SPINNER_FRAMES: Record<SymbolPreset, Record<SpinnerType, string[]>> = {
812
812
  },
813
813
  };
814
814
 
815
+ /**
816
+ * Shape accepted by `themeJson.symbols.spinnerFrames`. A flat array applies to
817
+ * both spinner types; an object lets a theme override `status` and/or
818
+ * `activity` independently. Anything not specified falls back to the symbol
819
+ * preset's default frames.
820
+ */
821
+ type SpinnerFramesOverride = string[] | { status?: string[]; activity?: string[] };
822
+
823
+ function normalizeSpinnerFramesOverride(
824
+ value: SpinnerFramesOverride | undefined,
825
+ ): Partial<Record<SpinnerType, string[]>> {
826
+ if (value === undefined) return {};
827
+ if (Array.isArray(value)) return { status: value, activity: value };
828
+ const result: Partial<Record<SpinnerType, string[]>> = {};
829
+ if (value.status) result.status = value.status;
830
+ if (value.activity) result.activity = value.activity;
831
+ return result;
832
+ }
833
+
815
834
  // ============================================================================
816
835
  // Types & Schema
817
836
  // ============================================================================
@@ -898,6 +917,19 @@ const themeColorsSchema = z.object(
898
917
  },
899
918
  );
900
919
 
920
+ const spinnerFramesArraySchema = z.array(z.string().min(1)).min(1);
921
+ const spinnerFramesSchema = z.union([
922
+ spinnerFramesArraySchema,
923
+ z
924
+ .object({
925
+ status: spinnerFramesArraySchema.optional(),
926
+ activity: spinnerFramesArraySchema.optional(),
927
+ })
928
+ .refine(value => value.status !== undefined || value.activity !== undefined, {
929
+ message: "spinnerFrames object must define `status` and/or `activity`",
930
+ }),
931
+ ]);
932
+
901
933
  const symbolPresetSchema = z.enum(["unicode", "nerd", "ascii"]);
902
934
 
903
935
  const themeJsonSchema = z.object({
@@ -916,6 +948,7 @@ const themeJsonSchema = z.object({
916
948
  .object({
917
949
  preset: symbolPresetSchema.optional(),
918
950
  overrides: z.record(z.string(), z.string()).optional(),
951
+ spinnerFrames: spinnerFramesSchema.optional(),
919
952
  })
920
953
  .optional(),
921
954
  });
@@ -1243,6 +1276,7 @@ export class Theme {
1243
1276
  #fgColors: Record<ThemeColor, string>;
1244
1277
  #bgColors: Record<ThemeBg, string>;
1245
1278
  #symbols: SymbolMap;
1279
+ #spinnerFramesOverrides: Partial<Record<SpinnerType, string[]>>;
1246
1280
 
1247
1281
  constructor(
1248
1282
  fgColors: Record<ThemeColor, string | number>,
@@ -1250,6 +1284,7 @@ export class Theme {
1250
1284
  private readonly mode: ColorMode,
1251
1285
  private readonly symbolPreset: SymbolPreset,
1252
1286
  symbolOverrides: Partial<Record<SymbolKey, string>>,
1287
+ spinnerFramesOverrides: Partial<Record<SpinnerType, string[]>> = {},
1253
1288
  ) {
1254
1289
  this.#fgColors = {} as Record<ThemeColor, string>;
1255
1290
  for (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) {
@@ -1269,6 +1304,7 @@ export class Theme {
1269
1304
  logger.debug("Invalid symbol key in override", { key, availableKeys: Object.keys(this.#symbols) });
1270
1305
  }
1271
1306
  }
1307
+ this.#spinnerFramesOverrides = spinnerFramesOverrides;
1272
1308
  }
1273
1309
 
1274
1310
  fg(color: ThemeColor, text: string): string {
@@ -1563,7 +1599,7 @@ export class Theme {
1563
1599
  * Get spinner frames by type.
1564
1600
  */
1565
1601
  getSpinnerFrames(type: SpinnerType = "status"): string[] {
1566
- return SPINNER_FRAMES[this.symbolPreset][type];
1602
+ return this.#spinnerFramesOverrides[type] ?? SPINNER_FRAMES[this.symbolPreset][type];
1567
1603
  }
1568
1604
 
1569
1605
  /**
@@ -1735,7 +1771,8 @@ function createTheme(themeJson: ThemeJson, options: CreateThemeOptions = {}): Th
1735
1771
  // Extract symbol configuration - settings override takes precedence over theme
1736
1772
  const symbolPreset: SymbolPreset = symbolPresetOverride ?? themeJson.symbols?.preset ?? "unicode";
1737
1773
  const symbolOverrides = themeJson.symbols?.overrides ?? {};
1738
- return new Theme(fgColors, bgColors, colorMode, symbolPreset, symbolOverrides);
1774
+ const spinnerFramesOverrides = normalizeSpinnerFramesOverride(themeJson.symbols?.spinnerFrames);
1775
+ return new Theme(fgColors, bgColors, colorMode, symbolPreset, symbolOverrides, spinnerFramesOverrides);
1739
1776
  }
1740
1777
 
1741
1778
  async function loadTheme(name: string, options: CreateThemeOptions = {}): Promise<Theme> {
@@ -7,6 +7,7 @@ import type { Settings } from "../config/settings";
7
7
  import type {
8
8
  ExtensionUIContext,
9
9
  ExtensionUIDialogOptions,
10
+ ExtensionUISelectItem,
10
11
  ExtensionWidgetContent,
11
12
  ExtensionWidgetOptions,
12
13
  } from "../extensibility/extensions";
@@ -24,7 +25,7 @@ import type { CustomEditor } from "./components/custom-editor";
24
25
  import type { EvalExecutionComponent } from "./components/eval-execution";
25
26
  import type { HookEditorComponent } from "./components/hook-editor";
26
27
  import type { HookInputComponent } from "./components/hook-input";
27
- import type { HookSelectorComponent } from "./components/hook-selector";
28
+ import type { HookSelectorComponent, HookSelectorOptions } from "./components/hook-selector";
28
29
  import type { StatusLineComponent } from "./components/status-line";
29
30
  import type { ToolExecutionHandle } from "./components/tool-execution";
30
31
  import type { LoopLimitRuntime } from "./loop-limit";
@@ -63,6 +64,8 @@ export interface InteractiveModeInitOptions {
63
64
  suppressWelcomeIntro?: boolean;
64
65
  }
65
66
 
67
+ export type InteractiveSelectorDialogOptions = ExtensionUIDialogOptions & Pick<HookSelectorOptions, "disabledIndices">;
68
+
66
69
  export interface InteractiveModeContext {
67
70
  // UI access
68
71
  ui: TUI;
@@ -241,6 +244,7 @@ export interface InteractiveModeContext {
241
244
  ): Promise<CompactionOutcome>;
242
245
  openInBrowser(urlOrPath: string): void;
243
246
  refreshSlashCommandState(cwd?: string): Promise<void>;
247
+ applyCwdChange(newCwd: string): Promise<void>;
244
248
 
245
249
  // Selector handling
246
250
  showSettingsSelector(): void;
@@ -297,8 +301,8 @@ export interface InteractiveModeContext {
297
301
  setHookStatus(key: string, text: string | undefined): void;
298
302
  showHookSelector(
299
303
  title: string,
300
- options: string[],
301
- dialogOptions?: ExtensionUIDialogOptions,
304
+ options: ExtensionUISelectItem[],
305
+ dialogOptions?: InteractiveSelectorDialogOptions,
302
306
  ): Promise<string | undefined>;
303
307
  hideHookSelector(): void;
304
308
  showHookInput(title: string, placeholder?: string): Promise<string | undefined>;
@@ -262,8 +262,11 @@ export class UiHelpers {
262
262
  break;
263
263
  }
264
264
  case "assistant": {
265
- const assistantComponent = new AssistantMessageComponent(message, this.ctx.hideThinkingBlock, () =>
266
- this.ctx.ui.requestRender(),
265
+ const assistantComponent = new AssistantMessageComponent(
266
+ message,
267
+ this.ctx.hideThinkingBlock,
268
+ () => this.ctx.ui.requestRender(),
269
+ this.ctx.session.extensionRunner?.getAssistantThinkingRenderers(),
267
270
  );
268
271
  this.ctx.chatContainer.addChild(assistantComponent);
269
272
  break;