@oh-my-pi/pi-coding-agent 15.7.2 → 15.7.5

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 (188) hide show
  1. package/CHANGELOG.md +114 -6
  2. package/dist/types/cli/args.d.ts +1 -1
  3. package/dist/types/cli/extension-flags.d.ts +36 -0
  4. package/dist/types/config/config-file.d.ts +4 -0
  5. package/dist/types/config/file-lock.d.ts +23 -0
  6. package/dist/types/config/keybindings.d.ts +2 -1
  7. package/dist/types/config/model-registry.d.ts +6 -0
  8. package/dist/types/config/settings-schema.d.ts +69 -65
  9. package/dist/types/edit/hashline/diff.d.ts +3 -3
  10. package/dist/types/eval/__tests__/agent-bridge.test.d.ts +1 -0
  11. package/dist/types/eval/__tests__/budget-bridge.test.d.ts +1 -0
  12. package/dist/types/eval/__tests__/idle-timeout.test.d.ts +1 -0
  13. package/dist/types/eval/agent-bridge.d.ts +25 -0
  14. package/dist/types/eval/backend.d.ts +17 -2
  15. package/dist/types/eval/budget-bridge.d.ts +29 -0
  16. package/dist/types/eval/idle-timeout.d.ts +28 -0
  17. package/dist/types/eval/js/executor.d.ts +8 -0
  18. package/dist/types/eval/js/tool-bridge.d.ts +2 -1
  19. package/dist/types/eval/py/executor.d.ts +13 -0
  20. package/dist/types/exec/bash-executor.d.ts +1 -0
  21. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  22. package/dist/types/extensibility/extensions/runner.d.ts +7 -0
  23. package/dist/types/extensibility/plugins/git-url.d.ts +11 -1
  24. package/dist/types/extensibility/plugins/manager.d.ts +12 -1
  25. package/dist/types/extensibility/shared-events.d.ts +2 -2
  26. package/dist/types/internal-urls/local-protocol.d.ts +19 -9
  27. package/dist/types/internal-urls/types.d.ts +14 -0
  28. package/dist/types/lsp/client.d.ts +3 -0
  29. package/dist/types/mcp/manager.d.ts +14 -5
  30. package/dist/types/memory-backend/index.d.ts +1 -1
  31. package/dist/types/memory-backend/resolve.d.ts +1 -1
  32. package/dist/types/memory-backend/types.d.ts +3 -3
  33. package/dist/types/mnemopi/backend.d.ts +4 -0
  34. package/dist/types/mnemopi/config.d.ts +29 -0
  35. package/dist/types/mnemopi/state.d.ts +72 -0
  36. package/dist/types/modes/components/custom-editor.d.ts +2 -2
  37. package/dist/types/modes/components/omfg-panel.d.ts +19 -0
  38. package/dist/types/modes/controllers/command-controller.d.ts +6 -0
  39. package/dist/types/modes/controllers/input-controller.d.ts +1 -3
  40. package/dist/types/modes/controllers/omfg-controller.d.ts +10 -0
  41. package/dist/types/modes/controllers/omfg-rule.d.ts +26 -0
  42. package/dist/types/modes/gradient-highlight.d.ts +5 -1
  43. package/dist/types/modes/interactive-mode.d.ts +7 -3
  44. package/dist/types/modes/magic-keywords.d.ts +14 -0
  45. package/dist/types/modes/markdown-prose.d.ts +27 -0
  46. package/dist/types/modes/orchestrate.d.ts +7 -2
  47. package/dist/types/modes/shared.d.ts +1 -1
  48. package/dist/types/modes/turn-budget.d.ts +18 -0
  49. package/dist/types/modes/types.d.ts +7 -3
  50. package/dist/types/modes/ultrathink.d.ts +7 -2
  51. package/dist/types/modes/workflow.d.ts +15 -0
  52. package/dist/types/sdk.d.ts +13 -3
  53. package/dist/types/session/agent-session.d.ts +36 -17
  54. package/dist/types/session/session-manager.d.ts +18 -0
  55. package/dist/types/session/session-storage.d.ts +6 -0
  56. package/dist/types/session/shake-types.d.ts +24 -0
  57. package/dist/types/task/executor.d.ts +2 -2
  58. package/dist/types/task/repair-args.d.ts +52 -0
  59. package/dist/types/tiny/models.d.ts +1 -1
  60. package/dist/types/tiny/title-client.d.ts +28 -2
  61. package/dist/types/tiny/title-protocol.d.ts +8 -5
  62. package/dist/types/tools/find.d.ts +1 -1
  63. package/dist/types/tools/index.d.ts +19 -3
  64. package/dist/types/tools/memory-edit.d.ts +1 -1
  65. package/dist/types/tools/path-utils.d.ts +7 -0
  66. package/dist/types/tui/output-block.d.ts +7 -7
  67. package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
  68. package/package.json +10 -10
  69. package/scripts/build-binary.ts +0 -1
  70. package/src/autoresearch/tools/run-experiment.ts +45 -113
  71. package/src/cli/args.ts +39 -16
  72. package/src/cli/extension-flags.ts +48 -0
  73. package/src/cli/plugin-cli.ts +11 -2
  74. package/src/cli.ts +59 -0
  75. package/src/config/config-file.ts +98 -13
  76. package/src/config/file-lock.ts +60 -17
  77. package/src/config/keybindings.ts +78 -27
  78. package/src/config/model-registry.ts +7 -1
  79. package/src/config/settings-schema.ts +73 -67
  80. package/src/config/settings.ts +22 -0
  81. package/src/edit/hashline/diff.ts +81 -24
  82. package/src/edit/renderer.ts +16 -12
  83. package/src/eval/__tests__/agent-bridge.test.ts +433 -0
  84. package/src/eval/__tests__/budget-bridge.test.ts +69 -0
  85. package/src/eval/__tests__/idle-timeout.test.ts +66 -0
  86. package/src/eval/__tests__/shared-executors.test.ts +21 -0
  87. package/src/eval/agent-bridge.ts +295 -0
  88. package/src/eval/backend.ts +17 -2
  89. package/src/eval/budget-bridge.ts +48 -0
  90. package/src/eval/idle-timeout.ts +80 -0
  91. package/src/eval/js/executor.ts +35 -7
  92. package/src/eval/js/index.ts +2 -1
  93. package/src/eval/js/shared/prelude.txt +85 -1
  94. package/src/eval/js/tool-bridge.ts +9 -0
  95. package/src/eval/py/executor.ts +41 -14
  96. package/src/eval/py/index.ts +2 -1
  97. package/src/eval/py/prelude.py +132 -1
  98. package/src/exec/bash-executor.ts +2 -3
  99. package/src/extensibility/custom-tools/types.ts +2 -2
  100. package/src/extensibility/extensions/runner.ts +12 -2
  101. package/src/extensibility/plugins/git-url.ts +90 -4
  102. package/src/extensibility/plugins/manager.ts +103 -7
  103. package/src/extensibility/shared-events.ts +2 -2
  104. package/src/internal-urls/docs-index.generated.ts +88 -88
  105. package/src/internal-urls/local-protocol.ts +23 -11
  106. package/src/internal-urls/types.ts +15 -0
  107. package/src/lsp/client.ts +28 -5
  108. package/src/main.ts +44 -55
  109. package/src/mcp/manager.ts +87 -4
  110. package/src/memory-backend/index.ts +1 -1
  111. package/src/memory-backend/resolve.ts +3 -3
  112. package/src/memory-backend/types.ts +3 -3
  113. package/src/{mnemosyne → mnemopi}/backend.ts +70 -70
  114. package/src/{mnemosyne → mnemopi}/config.ts +36 -36
  115. package/src/{mnemosyne → mnemopi}/state.ts +67 -67
  116. package/src/modes/components/agent-dashboard.ts +6 -6
  117. package/src/modes/components/custom-editor.ts +4 -11
  118. package/src/modes/components/extensions/state-manager.ts +3 -4
  119. package/src/modes/components/footer.ts +8 -9
  120. package/src/modes/components/hook-selector.ts +86 -20
  121. package/src/modes/components/oauth-selector.ts +93 -21
  122. package/src/modes/components/omfg-panel.ts +141 -0
  123. package/src/modes/components/settings-defs.ts +2 -2
  124. package/src/modes/components/tips.txt +2 -1
  125. package/src/modes/components/tool-execution.ts +38 -19
  126. package/src/modes/components/tree-selector.ts +4 -3
  127. package/src/modes/components/user-message-selector.ts +94 -19
  128. package/src/modes/components/user-message.ts +8 -1
  129. package/src/modes/controllers/command-controller.ts +25 -0
  130. package/src/modes/controllers/event-controller.ts +68 -3
  131. package/src/modes/controllers/input-controller.ts +14 -11
  132. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  133. package/src/modes/controllers/omfg-controller.ts +283 -0
  134. package/src/modes/controllers/omfg-rule.ts +647 -0
  135. package/src/modes/controllers/selector-controller.ts +1 -0
  136. package/src/modes/gradient-highlight.ts +23 -6
  137. package/src/modes/interactive-mode.ts +41 -7
  138. package/src/modes/magic-keywords.ts +20 -0
  139. package/src/modes/markdown-prose.ts +247 -0
  140. package/src/modes/orchestrate.ts +17 -11
  141. package/src/modes/shared.ts +3 -11
  142. package/src/modes/turn-budget.ts +31 -0
  143. package/src/modes/types.ts +7 -1
  144. package/src/modes/ultrathink.ts +16 -10
  145. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  146. package/src/modes/workflow.ts +42 -0
  147. package/src/prompts/system/omfg-user.md +51 -0
  148. package/src/prompts/system/project-prompt.md +3 -2
  149. package/src/prompts/system/subagent-system-prompt.md +12 -8
  150. package/src/prompts/system/system-prompt.md +9 -6
  151. package/src/prompts/system/workflow-notice.md +70 -0
  152. package/src/prompts/tools/eval.md +13 -1
  153. package/src/prompts/tools/memory-edit.md +1 -1
  154. package/src/sdk.ts +63 -33
  155. package/src/session/agent-session.ts +290 -55
  156. package/src/session/session-manager.ts +32 -0
  157. package/src/session/session-storage.ts +68 -8
  158. package/src/session/shake-types.ts +43 -0
  159. package/src/slash-commands/builtin-registry.ts +39 -16
  160. package/src/task/executor.ts +17 -7
  161. package/src/task/index.ts +9 -8
  162. package/src/task/repair-args.ts +117 -0
  163. package/src/tiny/models.ts +2 -2
  164. package/src/tiny/title-client.ts +133 -43
  165. package/src/tiny/title-protocol.ts +10 -5
  166. package/src/tiny/worker.ts +3 -46
  167. package/src/tools/ast-edit.ts +3 -0
  168. package/src/tools/ast-grep.ts +3 -0
  169. package/src/tools/eval.ts +202 -26
  170. package/src/tools/find.ts +20 -6
  171. package/src/tools/gh.ts +1 -0
  172. package/src/tools/grouped-file-output.ts +9 -2
  173. package/src/tools/index.ts +17 -5
  174. package/src/tools/memory-edit.ts +4 -4
  175. package/src/tools/memory-recall.ts +5 -5
  176. package/src/tools/memory-reflect.ts +5 -5
  177. package/src/tools/memory-retain.ts +4 -4
  178. package/src/tools/path-utils.ts +13 -2
  179. package/src/tools/read.ts +1 -0
  180. package/src/tools/render-utils.ts +2 -1
  181. package/src/tools/search.ts +491 -76
  182. package/src/tui/output-block.ts +37 -75
  183. package/src/utils/git.ts +9 -3
  184. package/dist/types/mnemosyne/backend.d.ts +0 -4
  185. package/dist/types/mnemosyne/config.d.ts +0 -29
  186. package/dist/types/mnemosyne/state.d.ts +0 -72
  187. /package/dist/types/{mnemosyne → mnemopi}/index.d.ts +0 -0
  188. /package/src/{mnemosyne → mnemopi}/index.ts +0 -0
@@ -1,5 +1,6 @@
1
1
  import { Container, Markdown, Spacer } from "@oh-my-pi/pi-tui";
2
2
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
3
+ import { highlightMagicKeywords } from "../magic-keywords";
3
4
 
4
5
  // OSC 133 shell integration: marks prompt zones for terminal multiplexers
5
6
  const OSC133_ZONE_START = "\x1b]133;A\x07";
@@ -13,9 +14,15 @@ export class UserMessageComponent extends Container {
13
14
  constructor(text: string, synthetic = false) {
14
15
  super();
15
16
  const bgColor = (value: string) => theme.bg("userMessageBg", value);
17
+ // Paint the magic keywords ("ultrathink"/"orchestrate"/"workflow") inside the rendered
18
+ // bubble too — matching the live editor glow. The Markdown component routes code spans and
19
+ // fenced blocks through its own code styling (never `color`), so those are already excluded;
20
+ // `highlightMagicKeywords` additionally restores the bubble's own foreground after each
21
+ // painted keyword so the gradient never bleeds into the rest of the line.
22
+ const keywordReset = theme.getFgAnsi("userMessageText") || "\x1b[39m";
16
23
  const color = synthetic
17
24
  ? (value: string) => theme.fg("dim", value)
18
- : (value: string) => theme.fg("userMessageText", value);
25
+ : (value: string) => theme.fg("userMessageText", highlightMagicKeywords(value, keywordReset));
19
26
  this.addChild(new Spacer(1));
20
27
  this.addChild(
21
28
  new Markdown(text, 1, 1, getMarkdownTheme(), {
@@ -39,6 +39,7 @@ import { buildToolsMarkdown } from "../../modes/utils/tools-markdown";
39
39
  import type { AsyncJobSnapshotItem } from "../../session/agent-session";
40
40
  import type { AuthStorage } from "../../session/auth-storage";
41
41
  import type { NewSessionOptions } from "../../session/session-manager";
42
+ import { formatShakeSummary, type ShakeMode, type ShakeResult } from "../../session/shake-types";
42
43
  import { outputMeta } from "../../tools/output-meta";
43
44
  import { resolveToCwd, stripOuterDoubleQuotes } from "../../tools/path-utils";
44
45
  import { replaceTabs } from "../../tools/render-utils";
@@ -1122,6 +1123,30 @@ export class CommandController {
1122
1123
  return this.executeCompaction(customInstructions, false);
1123
1124
  }
1124
1125
 
1126
+ /**
1127
+ * TUI handler for `/shake`. `elide` drops heavy structural content and
1128
+ * `images` strips image blocks. Rebuilds the chat and reports counts.
1129
+ */
1130
+ async handleShakeCommand(mode: ShakeMode): Promise<void> {
1131
+ let result: ShakeResult;
1132
+ try {
1133
+ result = await this.ctx.session.shake(mode);
1134
+ } catch (error) {
1135
+ this.ctx.showError(`Shake failed: ${error instanceof Error ? error.message : String(error)}`);
1136
+ return;
1137
+ }
1138
+
1139
+ const dropped = result.toolResultsDropped + result.blocksDropped + (result.imagesDropped ?? 0);
1140
+ if (dropped === 0) {
1141
+ this.ctx.showStatus("Nothing to shake.");
1142
+ return;
1143
+ }
1144
+ this.ctx.rebuildChatFromMessages();
1145
+ this.ctx.statusLine.invalidate();
1146
+ this.ctx.updateEditorTopBorder();
1147
+ this.ctx.showStatus(formatShakeSummary(result));
1148
+ }
1149
+
1125
1150
  async handleSkillCommand(skillPath: string, args: string): Promise<void> {
1126
1151
  try {
1127
1152
  const content = await Bun.file(skillPath).text();
@@ -25,6 +25,19 @@ type AgentSessionEventKind = AgentSessionEvent["type"];
25
25
 
26
26
  const IRC_MESSAGE_VISIBLE_TTL_MS = 10_000;
27
27
 
28
+ // Events that change foreground streaming state, or that reset a turn. The TUI
29
+ // eager native-scrollback rebuild mode is recomputed only on these so unrelated
30
+ // IRC/notices/status refreshes do not toggle scrollback replay policy.
31
+ const STREAM_RENDER_MODE_EVENTS: Record<string, true> = {
32
+ agent_start: true,
33
+ agent_end: true,
34
+ message_start: true,
35
+ message_end: true,
36
+ tool_execution_start: true,
37
+ tool_execution_update: true,
38
+ tool_execution_end: true,
39
+ };
40
+
28
41
  type AgentSessionEventHandlers = {
29
42
  [E in AgentSessionEventKind]: (event: Extract<AgentSessionEvent, { type: E }>) => Promise<void>;
30
43
  };
@@ -35,6 +48,7 @@ export class EventController {
35
48
  #renderedCustomMessages = new Set<string>();
36
49
  #lastIntent: string | undefined = undefined;
37
50
  #backgroundToolCallIds = new Set<string>();
51
+ #assistantMessageStreaming = false;
38
52
  #readToolCallArgs = new Map<string, Record<string, unknown>>();
39
53
  #readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
40
54
  #lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
@@ -158,12 +172,37 @@ export class EventController {
158
172
 
159
173
  const run = this.#handlers[event.type] as (e: AgentSessionEvent) => Promise<void>;
160
174
  await run(event);
175
+ // While assistant text or a foreground tool is streaming, rows above the
176
+ // viewport can re-layout after they have already entered native scrollback
177
+ // (Markdown fences, wrapping, previews). Let the TUI rebuild history on
178
+ // those offscreen edits instead of deferring, which otherwise leaves stale
179
+ // tail rows duplicated above the live viewport.
180
+ // Background-running tools are excluded so late async updates outside the
181
+ // active foreground stream keep the no-yank deferral; agent_start resets
182
+ // the mode at every turn boundary.
183
+ if (STREAM_RENDER_MODE_EVENTS[event.type]) {
184
+ this.#refreshToolRenderMode();
185
+ }
186
+ }
187
+
188
+ #refreshToolRenderMode(): void {
189
+ let foregroundToolActive = this.#assistantMessageStreaming;
190
+ if (!foregroundToolActive) {
191
+ for (const toolCallId of this.ctx.pendingTools.keys()) {
192
+ if (!this.#backgroundToolCallIds.has(toolCallId)) {
193
+ foregroundToolActive = true;
194
+ break;
195
+ }
196
+ }
197
+ }
198
+ this.ctx.ui.setEagerNativeScrollbackRebuild(foregroundToolActive);
161
199
  }
162
200
 
163
201
  async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
164
202
  this.#lastIntent = undefined;
165
203
  this.#readToolCallArgs.clear();
166
204
  this.#readToolCallAssistantComponents.clear();
205
+ this.#assistantMessageStreaming = false;
167
206
  this.#lastAssistantComponent = undefined;
168
207
  if (this.ctx.retryEscapeHandler) {
169
208
  this.ctx.editor.onEscape = this.ctx.retryEscapeHandler;
@@ -236,6 +275,7 @@ export class EventController {
236
275
  this.ctx.ui.requestRender();
237
276
  } else if (event.message.role === "assistant") {
238
277
  this.#lastThinkingCount = 0;
278
+ this.#assistantMessageStreaming = true;
239
279
  this.#resetReadGroup();
240
280
  this.ctx.streamingComponent = new AssistantMessageComponent(undefined, this.ctx.hideThinkingBlock, () =>
241
281
  this.ctx.ui.requestRender(),
@@ -382,6 +422,9 @@ export class EventController {
382
422
 
383
423
  async #handleMessageEnd(event: Extract<AgentSessionEvent, { type: "message_end" }>): Promise<void> {
384
424
  if (event.message.role === "user") return;
425
+ if (event.message.role === "assistant") {
426
+ this.#assistantMessageStreaming = false;
427
+ }
385
428
  if (this.ctx.streamingComponent && event.message.role === "assistant") {
386
429
  this.ctx.streamingMessage = event.message;
387
430
  let errorMessage: string | undefined;
@@ -564,8 +607,8 @@ export class EventController {
564
607
  }
565
608
  }
566
609
  }
567
-
568
610
  async #handleAgentEnd(_event: Extract<AgentSessionEvent, { type: "agent_end" }>): Promise<void> {
611
+ this.#assistantMessageStreaming = false;
569
612
  if (this.ctx.loadingAnimation) {
570
613
  this.ctx.loadingAnimation.stop();
571
614
  this.ctx.loadingAnimation = undefined;
@@ -610,7 +653,12 @@ export class EventController {
610
653
  : event.reason === "idle"
611
654
  ? "Idle "
612
655
  : "";
613
- const actionLabel = event.action === "handoff" ? "Auto-handoff" : "Auto context-full maintenance";
656
+ const actionLabel =
657
+ event.action === "handoff"
658
+ ? "Auto-handoff"
659
+ : event.action === "shake"
660
+ ? "Auto-shake"
661
+ : "Auto context-full maintenance";
614
662
  this.ctx.autoCompactionLoader = new Loader(
615
663
  this.ctx.ui,
616
664
  spinner => theme.fg("accent", spinner),
@@ -634,8 +682,25 @@ export class EventController {
634
682
  this.ctx.statusContainer.clear();
635
683
  }
636
684
  const isHandoffAction = event.action === "handoff";
685
+ const isShakeAction = event.action === "shake";
637
686
  if (event.aborted) {
638
- this.ctx.showStatus(isHandoffAction ? "Auto-handoff cancelled" : "Auto context-full maintenance cancelled");
687
+ this.ctx.showStatus(
688
+ isHandoffAction
689
+ ? "Auto-handoff cancelled"
690
+ : isShakeAction
691
+ ? "Auto-shake cancelled"
692
+ : "Auto context-full maintenance cancelled",
693
+ );
694
+ } else if (isShakeAction) {
695
+ // Shake produces no CompactionResult; rebuild on success, suppress benign skips.
696
+ if (event.errorMessage) {
697
+ this.ctx.showWarning(event.errorMessage);
698
+ } else if (!event.skipped) {
699
+ this.ctx.rebuildChatFromMessages();
700
+ this.ctx.statusLine.invalidate();
701
+ this.ctx.updateEditorTopBorder();
702
+ this.ctx.showStatus("Auto-shake completed");
703
+ }
639
704
  } else if (event.result) {
640
705
  this.ctx.rebuildChatFromMessages();
641
706
  this.ctx.statusLine.invalidate();
@@ -8,7 +8,6 @@ import { renderSegmentTrack } from "../../modes/components/segment-track";
8
8
  import { TinyTitleDownloadProgressComponent } from "../../modes/components/tiny-title-download-progress";
9
9
  import { expandEmoticons } from "../../modes/emoji-autocomplete";
10
10
  import { createPromptActionAutocompleteProvider } from "../../modes/prompt-action-autocomplete";
11
- import { theme } from "../../modes/theme/theme";
12
11
  import type { InteractiveModeContext } from "../../modes/types";
13
12
  import type { AgentSessionEvent } from "../../session/agent-session";
14
13
  import { SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails } from "../../session/messages";
@@ -91,6 +90,7 @@ export class InputController {
91
90
  Boolean(
92
91
  this.ctx.loadingAnimation ||
93
92
  this.ctx.hasActiveBtw() ||
93
+ this.ctx.hasActiveOmfg() ||
94
94
  this.ctx.session.isStreaming ||
95
95
  this.ctx.session.isCompacting ||
96
96
  this.ctx.session.isGeneratingHandoff ||
@@ -114,6 +114,9 @@ export class InputController {
114
114
  if (this.ctx.hasActiveBtw() && this.ctx.handleBtwEscape()) {
115
115
  return;
116
116
  }
117
+ if (this.ctx.hasActiveOmfg() && this.ctx.handleOmfgEscape()) {
118
+ return;
119
+ }
117
120
  if (this.ctx.loadingAnimation) {
118
121
  if (this.ctx.cancelPendingSubmission()) {
119
122
  return;
@@ -161,9 +164,9 @@ export class InputController {
161
164
  this.ctx.editor.setActionKeys("app.thinking.cycle", this.ctx.keybindings.getKeys("app.thinking.cycle"));
162
165
  this.ctx.editor.onCycleThinkingLevel = () => this.cycleThinkingLevel();
163
166
  this.ctx.editor.setActionKeys("app.model.cycleForward", this.ctx.keybindings.getKeys("app.model.cycleForward"));
164
- this.ctx.editor.onCycleModelForward = () => this.cycleRoleModel();
167
+ this.ctx.editor.onCycleModelForward = () => this.cycleRoleModel("forward");
165
168
  this.ctx.editor.setActionKeys("app.model.cycleBackward", this.ctx.keybindings.getKeys("app.model.cycleBackward"));
166
- this.ctx.editor.onCycleModelBackward = () => this.cycleRoleModel({ temporary: true });
169
+ this.ctx.editor.onCycleModelBackward = () => this.cycleRoleModel("backward");
167
170
  this.ctx.editor.setActionKeys(
168
171
  "app.model.selectTemporary",
169
172
  this.ctx.keybindings.getKeys("app.model.selectTemporary"),
@@ -180,7 +183,6 @@ export class InputController {
180
183
  this.ctx.editor.onToggleThinking = () => this.ctx.toggleThinkingBlockVisibility();
181
184
  this.ctx.editor.setActionKeys("app.editor.external", this.ctx.keybindings.getKeys("app.editor.external"));
182
185
  this.ctx.editor.onExternalEditor = () => void this.openExternalEditor();
183
- this.ctx.editor.onShowHotkeys = () => this.ctx.handleHotkeysCommand();
184
186
  this.ctx.editor.setActionKeys(
185
187
  "app.clipboard.pasteImage",
186
188
  this.ctx.keybindings.getKeys("app.clipboard.pasteImage"),
@@ -606,6 +608,9 @@ export class InputController {
606
608
  if (this.ctx.hasActiveBtw()) {
607
609
  this.ctx.handleBtwEscape();
608
610
  }
611
+ if (this.ctx.hasActiveOmfg()) {
612
+ this.ctx.handleOmfgEscape();
613
+ }
609
614
 
610
615
  this.ctx.isBackgrounded = true;
611
616
  const backgroundUiContext = this.ctx.createBackgroundUiContext();
@@ -760,10 +765,10 @@ export class InputController {
760
765
  }
761
766
  }
762
767
 
763
- async cycleRoleModel(options?: { temporary?: boolean }): Promise<void> {
768
+ async cycleRoleModel(direction: "forward" | "backward" = "forward"): Promise<void> {
764
769
  try {
765
770
  const cycleOrder = settings.get("cycleOrder");
766
- const result = await this.ctx.session.cycleRoleModels(cycleOrder, options);
771
+ const result = await this.ctx.session.cycleRoleModels(cycleOrder, direction);
767
772
  if (!result) {
768
773
  this.ctx.showStatus("Only one role model available");
769
774
  return;
@@ -773,14 +778,12 @@ export class InputController {
773
778
  this.ctx.updateEditorBorderColor();
774
779
  // The status line already reports the resolved model + thinking level, so
775
780
  // the cycle status is just a status-line-style chip track (active role
776
- // filled), matching the plan-approval model slider. A dim suffix flags a
777
- // temporary switch since that isn't shown elsewhere.
781
+ // filled), matching the plan-approval model slider.
778
782
  const track = renderSegmentTrack(
779
783
  cycleOrder.map(role => ({ label: role, color: getRoleInfo(role, settings).color })),
780
784
  cycleOrder.indexOf(result.role),
781
785
  );
782
- const tempLabel = options?.temporary ? theme.fg("dim", " (temporary)") : "";
783
- this.ctx.showStatus(`${track}${tempLabel}`, { dim: false });
786
+ this.ctx.showStatus(track, { dim: false });
784
787
  } catch (error) {
785
788
  this.ctx.showError(error instanceof Error ? error.message : String(error));
786
789
  }
@@ -797,7 +800,7 @@ export class InputController {
797
800
  child.setExpanded(expanded);
798
801
  }
799
802
  }
800
- this.ctx.ui.requestRender();
803
+ this.ctx.ui.requestRender(false, { allowUnknownViewportMutation: true });
801
804
  }
802
805
 
803
806
  toggleThinkingBlockVisibility(): void {
@@ -1425,7 +1425,7 @@ export class MCPCommandController {
1425
1425
  this.#showMessage(["", theme.fg("muted", `Reconnecting to "${name}"...`), ""].join("\n"));
1426
1426
 
1427
1427
  try {
1428
- const connection = await this.ctx.mcpManager.reconnectServer(name);
1428
+ const connection = await this.ctx.mcpManager.reconnectServer(name, { manual: true });
1429
1429
  if (connection) {
1430
1430
  // refreshMCPTools re-registers tools and preserves the user's prior
1431
1431
  // MCP tool selection. No need to call activateDiscoveredMCPTools —
@@ -0,0 +1,283 @@
1
+ import * as path from "node:path";
2
+ import { CONFIG_DIR_NAME, prompt } from "@oh-my-pi/pi-utils";
3
+ import type { Rule } from "../../capability/rule";
4
+ import omfgUserPrompt from "../../prompts/system/omfg-user.md" with { type: "text" };
5
+ import { shortenPath } from "../../tools/render-utils";
6
+ import { OmfgPanelComponent } from "../components/omfg-panel";
7
+ import type { InteractiveModeContext } from "../types";
8
+ import {
9
+ buildOmfgRuleForPath,
10
+ extractGeneratedRuleJson,
11
+ type OmfgRuleSourceLevel,
12
+ type ParsedGeneratedRule,
13
+ parseGeneratedRule,
14
+ validateParsedRuleAgainstAssistantHistory,
15
+ } from "./omfg-rule";
16
+
17
+ interface OmfgRequest {
18
+ component: OmfgPanelComponent;
19
+ abortController: AbortController;
20
+ complaint: string;
21
+ }
22
+
23
+ interface OmfgCandidate extends ParsedGeneratedRule {
24
+ validated: boolean;
25
+ }
26
+
27
+ interface GenerateCandidateOptions {
28
+ initialFeedback?: string;
29
+ previousRule?: string;
30
+ }
31
+
32
+ type SaveCandidateResult = { kind: "saved" | "aborted" | "rejected" } | { kind: "amend"; feedback: string };
33
+
34
+ const MAX_ATTEMPTS = 3;
35
+ const PROJECT_OPTION = "This project (.omp/rules)";
36
+ const GLOBAL_OPTION = "Global — all projects (~/.omp/agent/rules)";
37
+ const AMEND_OPTION = "Amend with feedback…";
38
+
39
+ export class OmfgController {
40
+ #activeRequest: OmfgRequest | undefined;
41
+
42
+ constructor(private readonly ctx: InteractiveModeContext) {}
43
+
44
+ hasActiveRequest(): boolean {
45
+ return this.#activeRequest !== undefined;
46
+ }
47
+
48
+ handleEscape(): boolean {
49
+ if (!this.#activeRequest) return false;
50
+ this.#closeActiveRequest({ abort: this.#activeRequest.abortController.signal.aborted === false });
51
+ return true;
52
+ }
53
+
54
+ dispose(): void {
55
+ this.#closeActiveRequest({ abort: true });
56
+ }
57
+
58
+ async start(complaint: string): Promise<void> {
59
+ const trimmedComplaint = complaint.trim();
60
+ if (!trimmedComplaint) {
61
+ this.ctx.showStatus("Usage: /omfg <complaint>");
62
+ return;
63
+ }
64
+
65
+ const model = this.ctx.session.model;
66
+ if (!model) {
67
+ this.ctx.showError("No active model available for /omfg.");
68
+ return;
69
+ }
70
+
71
+ this.#closeActiveRequest({ abort: true });
72
+
73
+ const request: OmfgRequest = {
74
+ component: new OmfgPanelComponent({ complaint: trimmedComplaint, tui: this.ctx.ui }),
75
+ abortController: new AbortController(),
76
+ complaint: trimmedComplaint,
77
+ };
78
+ this.ctx.omfgContainer.clear();
79
+ this.ctx.omfgContainer.addChild(request.component);
80
+ this.ctx.ui.requestRender();
81
+ this.#activeRequest = request;
82
+ void this.#runRequest(request);
83
+ }
84
+
85
+ async #runRequest(request: OmfgRequest): Promise<void> {
86
+ try {
87
+ let candidate = await this.#generateCandidate(request);
88
+ for (;;) {
89
+ if (!this.#isActiveRequest(request)) return;
90
+ if (!candidate) {
91
+ request.component.markError("The model did not return a valid TTSR rule.");
92
+ return;
93
+ }
94
+
95
+ if (!candidate.validated) {
96
+ request.component.setStatus("confirming", "Couldn't confirm a conversation match.");
97
+ const shouldSave = await this.ctx.showHookConfirm(
98
+ "Validation",
99
+ "Couldn't confirm this rule matches the conversation. Save anyway?",
100
+ );
101
+ if (!this.#isActiveRequest(request)) return;
102
+ if (!shouldSave) {
103
+ request.component.markRejected();
104
+ return;
105
+ }
106
+ }
107
+
108
+ const saveResult = await this.#saveCandidate(request, candidate);
109
+ if (!this.#isActiveRequest(request)) return;
110
+ if (saveResult.kind !== "amend") {
111
+ return;
112
+ }
113
+
114
+ candidate = await this.#generateCandidate(request, {
115
+ initialFeedback: `User requested this amendment before saving:\n${saveResult.feedback}`,
116
+ previousRule: candidate.fileContent,
117
+ });
118
+ }
119
+ } catch (error) {
120
+ if (!this.#isActiveRequest(request)) {
121
+ return;
122
+ }
123
+ if (request.abortController.signal.aborted) {
124
+ request.component.markAborted();
125
+ return;
126
+ }
127
+ request.component.markError(error instanceof Error ? error.message : String(error));
128
+ }
129
+ }
130
+
131
+ async #generateCandidate(
132
+ request: OmfgRequest,
133
+ options: GenerateCandidateOptions = {},
134
+ ): Promise<OmfgCandidate | undefined> {
135
+ const failedAttempts = options.initialFeedback ? [options.initialFeedback] : [];
136
+ let previousRule = options.previousRule;
137
+ let lastCandidate: ParsedGeneratedRule | undefined;
138
+
139
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
140
+ if (this.#shouldStop(request)) return undefined;
141
+ request.component.setRule("");
142
+ request.component.setStatus("generating", `Attempt ${attempt}/${MAX_ATTEMPTS} · generating…`);
143
+ const promptText = prompt.render(omfgUserPrompt, {
144
+ complaint: request.complaint,
145
+ feedback: failedAttempts.length > 0 ? failedAttempts.join("\n\n") : undefined,
146
+ previousRule,
147
+ });
148
+ const { replyText } = await this.ctx.session.runEphemeralTurn({
149
+ promptText,
150
+ dedupeReply: false,
151
+ onTextDelta: delta => {
152
+ if (this.#isActiveRequest(request)) {
153
+ request.component.appendDraft(delta);
154
+ }
155
+ },
156
+ signal: request.abortController.signal,
157
+ });
158
+ if (this.#shouldStop(request)) return undefined;
159
+
160
+ const parsed = parseGeneratedRule(replyText);
161
+ if ("error" in parsed) {
162
+ const failedRule = extractGeneratedRuleJson(replyText) ?? replyText.trim();
163
+ failedAttempts.push(
164
+ `Attempt ${attempt} failed: invalid rule (${parsed.error}).\nFailed candidate:\n${failedRule}`,
165
+ );
166
+ previousRule = failedRule;
167
+ request.component.setStatus("validating", `Attempt ${attempt}/${MAX_ATTEMPTS} · ${parsed.error}`);
168
+ continue;
169
+ }
170
+
171
+ request.component.setRule(parsed.fileContent);
172
+ request.component.setStatus("validating", `Attempt ${attempt}/${MAX_ATTEMPTS} · validating…`);
173
+ const validated = validateParsedRuleAgainstAssistantHistory(parsed, this.ctx.session.messages);
174
+ if (validated.repairedCondition) {
175
+ request.component.setRule(validated.candidate.fileContent);
176
+ }
177
+ if (validated.validation.matched) {
178
+ return { ...validated.candidate, validated: true };
179
+ }
180
+
181
+ lastCandidate = validated.candidate;
182
+ const failure =
183
+ validated.validation.feedback ?? "The rule condition did not match any earlier assistant output.";
184
+ failedAttempts.push(
185
+ `Attempt ${attempt} failed validation:\n${failure}\nFailed candidate:\n${validated.candidate.fileContent}`,
186
+ );
187
+ previousRule = validated.candidate.fileContent;
188
+ }
189
+
190
+ return lastCandidate ? { ...lastCandidate, validated: false } : undefined;
191
+ }
192
+
193
+ async #saveCandidate(request: OmfgRequest, candidate: OmfgCandidate): Promise<SaveCandidateResult> {
194
+ if (this.#shouldStop(request)) return { kind: "aborted" };
195
+ request.component.setStatus("saving", "Choose where to save or amend the TTSR rule…");
196
+ const location = await this.ctx.showHookSelector("Save TTSR rule where?", [
197
+ PROJECT_OPTION,
198
+ GLOBAL_OPTION,
199
+ AMEND_OPTION,
200
+ ]);
201
+ if (!this.#isActiveRequest(request)) return { kind: "aborted" };
202
+ if (!location) {
203
+ request.component.markAborted();
204
+ this.#closeActiveRequest({ abort: false });
205
+ return { kind: "aborted" };
206
+ }
207
+
208
+ if (location === AMEND_OPTION) {
209
+ request.component.setStatus("confirming", "Describe how to amend the rule…");
210
+ const amendment = await this.ctx.showHookInput(
211
+ "Amend TTSR rule",
212
+ "e.g. Make it specific to Ruby string eval in tool:write(*.rb)",
213
+ );
214
+ if (!this.#isActiveRequest(request)) return { kind: "aborted" };
215
+ const feedback = amendment?.trim();
216
+ if (!feedback) {
217
+ request.component.markAborted();
218
+ this.#closeActiveRequest({ abort: false });
219
+ return { kind: "aborted" };
220
+ }
221
+ return { kind: "amend", feedback };
222
+ }
223
+
224
+ const target = this.#resolveTarget(location, candidate.rule.name);
225
+ if (await Bun.file(target.filePath).exists()) {
226
+ const shouldOverwrite = await this.ctx.showHookConfirm(
227
+ "Overwrite TTSR rule?",
228
+ `${shortenPath(target.filePath)} already exists. Overwrite it?`,
229
+ );
230
+ if (!this.#isActiveRequest(request)) return { kind: "aborted" };
231
+ if (!shouldOverwrite) {
232
+ request.component.markRejected();
233
+ return { kind: "rejected" };
234
+ }
235
+ }
236
+
237
+ request.component.setStatus("saving", `Saving ${candidate.rule.name}…`);
238
+ await Bun.write(target.filePath, candidate.fileContent);
239
+ if (!this.#isActiveRequest(request)) return { kind: "aborted" };
240
+
241
+ const savedRule = buildOmfgRuleForPath(candidate.rule.name, candidate.fileContent, target.filePath, target.level);
242
+ this.#registerLive(savedRule);
243
+ request.component.markSaved(shortenPath(target.filePath));
244
+ return { kind: "saved" };
245
+ }
246
+
247
+ #resolveTarget(location: string, ruleName: string): { filePath: string; level: OmfgRuleSourceLevel } {
248
+ if (location === GLOBAL_OPTION) {
249
+ return {
250
+ filePath: path.join(this.ctx.settings.getAgentDir(), "rules", `${ruleName}.md`),
251
+ level: "user",
252
+ };
253
+ }
254
+ return {
255
+ filePath: path.join(this.ctx.sessionManager.getCwd(), CONFIG_DIR_NAME, "rules", `${ruleName}.md`),
256
+ level: "project",
257
+ };
258
+ }
259
+
260
+ #registerLive(rule: Rule): void {
261
+ this.ctx.session.ttsrManager?.addRule(rule);
262
+ }
263
+
264
+ #closeActiveRequest(options: { abort: boolean }): void {
265
+ const request = this.#activeRequest;
266
+ if (!request) return;
267
+ this.#activeRequest = undefined;
268
+ if (options.abort) {
269
+ request.abortController.abort();
270
+ }
271
+ request.component.close();
272
+ this.ctx.omfgContainer.clear();
273
+ this.ctx.ui.requestRender();
274
+ }
275
+
276
+ #isActiveRequest(request: OmfgRequest): boolean {
277
+ return this.#activeRequest === request;
278
+ }
279
+
280
+ #shouldStop(request: OmfgRequest): boolean {
281
+ return !this.#isActiveRequest(request) || request.abortController.signal.aborted;
282
+ }
283
+ }