@oh-my-pi/pi-coding-agent 15.7.1 → 15.7.3

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 (177) hide show
  1. package/CHANGELOG.md +95 -6
  2. package/dist/types/auto-thinking/classifier.d.ts +35 -0
  3. package/dist/types/cli/args.d.ts +1 -1
  4. package/dist/types/cli/extension-flags.d.ts +36 -0
  5. package/dist/types/config/config-file.d.ts +4 -0
  6. package/dist/types/config/file-lock.d.ts +23 -0
  7. package/dist/types/config/keybindings.d.ts +2 -1
  8. package/dist/types/config/model-registry.d.ts +6 -0
  9. package/dist/types/config/settings-schema.d.ts +112 -69
  10. package/dist/types/edit/hashline/diff.d.ts +9 -3
  11. package/dist/types/eval/__tests__/agent-bridge.test.d.ts +1 -0
  12. package/dist/types/eval/__tests__/budget-bridge.test.d.ts +1 -0
  13. package/dist/types/eval/__tests__/idle-timeout.test.d.ts +1 -0
  14. package/dist/types/eval/agent-bridge.d.ts +25 -0
  15. package/dist/types/eval/backend.d.ts +17 -2
  16. package/dist/types/eval/budget-bridge.d.ts +29 -0
  17. package/dist/types/eval/idle-timeout.d.ts +28 -0
  18. package/dist/types/eval/js/executor.d.ts +8 -0
  19. package/dist/types/eval/js/tool-bridge.d.ts +2 -1
  20. package/dist/types/eval/py/executor.d.ts +13 -0
  21. package/dist/types/exec/bash-executor.d.ts +1 -0
  22. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  23. package/dist/types/extensibility/extensions/runner.d.ts +7 -0
  24. package/dist/types/extensibility/plugins/git-url.d.ts +11 -1
  25. package/dist/types/extensibility/plugins/manager.d.ts +12 -1
  26. package/dist/types/extensibility/shared-events.d.ts +2 -2
  27. package/dist/types/memory-backend/index.d.ts +1 -1
  28. package/dist/types/memory-backend/resolve.d.ts +1 -1
  29. package/dist/types/memory-backend/types.d.ts +3 -3
  30. package/dist/types/mnemopi/backend.d.ts +4 -0
  31. package/dist/types/mnemopi/config.d.ts +29 -0
  32. package/dist/types/mnemopi/state.d.ts +72 -0
  33. package/dist/types/modes/components/custom-editor.d.ts +2 -2
  34. package/dist/types/modes/components/model-selector.d.ts +3 -2
  35. package/dist/types/modes/components/omfg-panel.d.ts +19 -0
  36. package/dist/types/modes/controllers/command-controller.d.ts +7 -0
  37. package/dist/types/modes/controllers/input-controller.d.ts +1 -3
  38. package/dist/types/modes/controllers/omfg-controller.d.ts +10 -0
  39. package/dist/types/modes/controllers/omfg-rule.d.ts +26 -0
  40. package/dist/types/modes/gradient-highlight.d.ts +5 -1
  41. package/dist/types/modes/interactive-mode.d.ts +7 -3
  42. package/dist/types/modes/magic-keywords.d.ts +14 -0
  43. package/dist/types/modes/markdown-prose.d.ts +27 -0
  44. package/dist/types/modes/orchestrate.d.ts +7 -2
  45. package/dist/types/modes/shared.d.ts +1 -1
  46. package/dist/types/modes/theme/theme.d.ts +2 -1
  47. package/dist/types/modes/turn-budget.d.ts +18 -0
  48. package/dist/types/modes/types.d.ts +7 -3
  49. package/dist/types/modes/ultrathink.d.ts +7 -2
  50. package/dist/types/modes/workflow.d.ts +15 -0
  51. package/dist/types/sdk.d.ts +15 -4
  52. package/dist/types/session/agent-session.d.ts +59 -23
  53. package/dist/types/session/session-manager.d.ts +18 -0
  54. package/dist/types/session/session-storage.d.ts +6 -0
  55. package/dist/types/session/shake-types.d.ts +24 -0
  56. package/dist/types/task/executor.d.ts +2 -2
  57. package/dist/types/thinking.d.ts +39 -1
  58. package/dist/types/tiny/device.d.ts +3 -3
  59. package/dist/types/tiny/models.d.ts +34 -1
  60. package/dist/types/tiny/title-protocol.d.ts +4 -0
  61. package/dist/types/tools/index.d.ts +19 -3
  62. package/dist/types/tools/memory-edit.d.ts +1 -1
  63. package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
  64. package/package.json +10 -10
  65. package/src/auto-thinking/classifier.ts +180 -0
  66. package/src/autoresearch/tools/run-experiment.ts +45 -113
  67. package/src/cli/args.ts +39 -16
  68. package/src/cli/extension-flags.ts +48 -0
  69. package/src/cli/plugin-cli.ts +11 -2
  70. package/src/config/config-file.ts +98 -13
  71. package/src/config/file-lock.ts +60 -17
  72. package/src/config/keybindings.ts +78 -27
  73. package/src/config/model-registry.ts +7 -1
  74. package/src/config/settings-schema.ts +118 -71
  75. package/src/config/settings.ts +12 -0
  76. package/src/edit/hashline/diff.ts +87 -22
  77. package/src/edit/renderer.ts +16 -12
  78. package/src/edit/streaming.ts +17 -6
  79. package/src/eval/__tests__/agent-bridge.test.ts +433 -0
  80. package/src/eval/__tests__/budget-bridge.test.ts +69 -0
  81. package/src/eval/__tests__/idle-timeout.test.ts +66 -0
  82. package/src/eval/__tests__/shared-executors.test.ts +53 -0
  83. package/src/eval/agent-bridge.ts +295 -0
  84. package/src/eval/backend.ts +17 -2
  85. package/src/eval/budget-bridge.ts +48 -0
  86. package/src/eval/idle-timeout.ts +80 -0
  87. package/src/eval/js/executor.ts +35 -7
  88. package/src/eval/js/index.ts +2 -1
  89. package/src/eval/js/shared/local-module-loader.ts +75 -10
  90. package/src/eval/js/shared/prelude.txt +85 -1
  91. package/src/eval/js/tool-bridge.ts +9 -0
  92. package/src/eval/py/executor.ts +41 -14
  93. package/src/eval/py/index.ts +2 -1
  94. package/src/eval/py/prelude.py +132 -1
  95. package/src/exec/bash-executor.ts +2 -3
  96. package/src/extensibility/custom-tools/types.ts +2 -2
  97. package/src/extensibility/extensions/runner.ts +12 -2
  98. package/src/extensibility/plugins/git-url.ts +90 -4
  99. package/src/extensibility/plugins/manager.ts +103 -7
  100. package/src/extensibility/shared-events.ts +2 -2
  101. package/src/internal-urls/docs-index.generated.ts +88 -88
  102. package/src/main.ts +50 -56
  103. package/src/memory-backend/index.ts +1 -1
  104. package/src/memory-backend/resolve.ts +3 -3
  105. package/src/memory-backend/types.ts +3 -3
  106. package/src/{mnemosyne → mnemopi}/backend.ts +70 -70
  107. package/src/{mnemosyne → mnemopi}/config.ts +36 -36
  108. package/src/{mnemosyne → mnemopi}/state.ts +67 -67
  109. package/src/modes/acp/acp-agent.ts +13 -3
  110. package/src/modes/components/agent-dashboard.ts +6 -6
  111. package/src/modes/components/custom-editor.ts +4 -11
  112. package/src/modes/components/extensions/state-manager.ts +3 -4
  113. package/src/modes/components/footer.ts +18 -12
  114. package/src/modes/components/hook-selector.ts +86 -20
  115. package/src/modes/components/model-selector.ts +20 -11
  116. package/src/modes/components/oauth-selector.ts +93 -21
  117. package/src/modes/components/omfg-panel.ts +141 -0
  118. package/src/modes/components/settings-defs.ts +9 -2
  119. package/src/modes/components/settings-selector.ts +4 -1
  120. package/src/modes/components/status-line/segments.ts +13 -5
  121. package/src/modes/components/tips.txt +2 -1
  122. package/src/modes/components/tool-execution.ts +38 -19
  123. package/src/modes/components/tree-selector.ts +4 -3
  124. package/src/modes/components/user-message-selector.ts +94 -19
  125. package/src/modes/components/user-message.ts +8 -1
  126. package/src/modes/controllers/command-controller.ts +57 -0
  127. package/src/modes/controllers/event-controller.ts +65 -3
  128. package/src/modes/controllers/input-controller.ts +14 -11
  129. package/src/modes/controllers/omfg-controller.ts +283 -0
  130. package/src/modes/controllers/omfg-rule.ts +647 -0
  131. package/src/modes/controllers/selector-controller.ts +21 -6
  132. package/src/modes/gradient-highlight.ts +23 -6
  133. package/src/modes/interactive-mode.ts +41 -7
  134. package/src/modes/magic-keywords.ts +20 -0
  135. package/src/modes/markdown-prose.ts +247 -0
  136. package/src/modes/orchestrate.ts +17 -11
  137. package/src/modes/shared.ts +3 -11
  138. package/src/modes/theme/theme.ts +6 -0
  139. package/src/modes/turn-budget.ts +31 -0
  140. package/src/modes/types.ts +7 -1
  141. package/src/modes/ultrathink.ts +16 -10
  142. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  143. package/src/modes/workflow.ts +42 -0
  144. package/src/prompts/system/auto-thinking-difficulty-local.md +14 -0
  145. package/src/prompts/system/auto-thinking-difficulty.md +12 -0
  146. package/src/prompts/system/omfg-user.md +51 -0
  147. package/src/prompts/system/system-prompt.md +1 -0
  148. package/src/prompts/system/workflow-notice.md +70 -0
  149. package/src/prompts/tools/eval.md +13 -1
  150. package/src/prompts/tools/memory-edit.md +1 -1
  151. package/src/sdk.ts +86 -38
  152. package/src/session/agent-session.ts +558 -80
  153. package/src/session/session-manager.ts +32 -0
  154. package/src/session/session-storage.ts +68 -8
  155. package/src/session/shake-types.ts +44 -0
  156. package/src/slash-commands/builtin-registry.ts +41 -16
  157. package/src/task/executor.ts +3 -3
  158. package/src/task/index.ts +6 -6
  159. package/src/thinking.ts +73 -1
  160. package/src/tiny/device.ts +4 -10
  161. package/src/tiny/models.ts +54 -2
  162. package/src/tiny/title-protocol.ts +11 -1
  163. package/src/tiny/worker.ts +19 -7
  164. package/src/tools/eval.ts +202 -26
  165. package/src/tools/grouped-file-output.ts +9 -2
  166. package/src/tools/index.ts +17 -5
  167. package/src/tools/memory-edit.ts +4 -4
  168. package/src/tools/memory-recall.ts +5 -5
  169. package/src/tools/memory-reflect.ts +5 -5
  170. package/src/tools/memory-retain.ts +4 -4
  171. package/src/tools/render-utils.ts +2 -1
  172. package/src/tools/search.ts +480 -76
  173. package/dist/types/mnemosyne/backend.d.ts +0 -4
  174. package/dist/types/mnemosyne/config.d.ts +0 -29
  175. package/dist/types/mnemosyne/state.d.ts +0 -72
  176. /package/dist/types/{mnemosyne → mnemopi}/index.d.ts +0 -0
  177. /package/src/{mnemosyne → mnemopi}/index.ts +0 -0
@@ -1,5 +1,11 @@
1
+ import { maskNonProse } from "./markdown-prose";
1
2
  import { theme } from "./theme/theme";
2
3
 
4
+ /** A gradient keyword highlighter. `resetTo` is the SGR foreground sequence
5
+ * re-emitted after each painted keyword so surrounding text keeps its color;
6
+ * it defaults to a plain foreground reset (editor / default-colored text). */
7
+ export type KeywordHighlighter = (text: string, resetTo?: string) => string;
8
+
3
9
  const FG_RESET = "\x1b[39m";
4
10
 
5
11
  /** Declarative spec for {@link createGradientHighlighter}. */
@@ -25,7 +31,7 @@ export interface GradientHighlightSpec {
25
31
  * untouched when `probe` does not match. The palette is compiled lazily and
26
32
  * memoized per active color mode.
27
33
  */
28
- export function createGradientHighlighter(spec: GradientHighlightSpec): (text: string) => string {
34
+ export function createGradientHighlighter(spec: GradientHighlightSpec): KeywordHighlighter {
29
35
  const { probe, highlight, stops, hue, saturation = 90, lightness = 62 } = spec;
30
36
 
31
37
  let cachedMode: string | undefined;
@@ -45,8 +51,8 @@ export function createGradientHighlighter(spec: GradientHighlightSpec): (text: s
45
51
  return next;
46
52
  };
47
53
 
48
- /** Paint each character of `word` with the next gradient stop, resetting fg after. */
49
- const paint = (word: string): string => {
54
+ /** Paint each character of `word` with the next gradient stop, restoring `resetTo` after. */
55
+ const paint = (word: string, resetTo: string): string => {
50
56
  const stopsArr = palette();
51
57
  const n = word.length;
52
58
  let out = "";
@@ -60,11 +66,22 @@ export function createGradientHighlighter(spec: GradientHighlightSpec): (text: s
60
66
  }
61
67
  out += word[i];
62
68
  }
63
- return `${out}${FG_RESET}`;
69
+ return `${out}${resetTo}`;
64
70
  };
65
71
 
66
- return (text: string): string => {
72
+ return (text: string, resetTo: string = FG_RESET): string => {
67
73
  if (!probe.test(text)) return text;
68
- return text.replace(highlight, paint);
74
+ // Match against a code/markup-masked copy so keywords inside code spans,
75
+ // fenced blocks, or XML sections never paint; indices still address `text`.
76
+ const masked = maskNonProse(text);
77
+ let out = "";
78
+ let last = 0;
79
+ for (const m of masked.matchAll(highlight)) {
80
+ const start = m.index ?? 0;
81
+ const end = start + m[0].length;
82
+ out += text.slice(last, start) + paint(text.slice(start, end), resetTo);
83
+ last = end;
84
+ }
85
+ return out + text.slice(last);
69
86
  };
70
87
  }
@@ -62,6 +62,7 @@ import type { AgentSession, AgentSessionEvent, ResolvedRoleModel } from "../sess
62
62
  import { HistoryStorage } from "../session/history-storage";
63
63
  import type { SessionContext, SessionManager } from "../session/session-manager";
64
64
  import { getRecentSessions } from "../session/session-manager";
65
+ import type { ShakeMode } from "../session/shake-types";
65
66
  import { formatDuration } from "../slash-commands/helpers/format";
66
67
  import { STTController, type SttState } from "../stt";
67
68
  import type { LspStartupServerInfo } from "../tools";
@@ -91,6 +92,7 @@ import { EventController } from "./controllers/event-controller";
91
92
  import { ExtensionUiController } from "./controllers/extension-ui-controller";
92
93
  import { InputController } from "./controllers/input-controller";
93
94
  import { MCPCommandController } from "./controllers/mcp-command-controller";
95
+ import { OmfgController } from "./controllers/omfg-controller";
94
96
  import { SelectorController } from "./controllers/selector-controller";
95
97
  import { SSHCommandController } from "./controllers/ssh-command-controller";
96
98
  import { TodoCommandController } from "./controllers/todo-command-controller";
@@ -236,6 +238,7 @@ export class InteractiveMode implements InteractiveModeContext {
236
238
  statusContainer: Container;
237
239
  todoContainer: Container;
238
240
  btwContainer: Container;
241
+ omfgContainer: Container;
239
242
  editor: CustomEditor;
240
243
  editorContainer: Container;
241
244
  hookWidgetContainerAbove: Container;
@@ -315,6 +318,7 @@ export class InteractiveMode implements InteractiveModeContext {
315
318
  readonly #toolUiContextSetter: (uiContext: ExtensionUIContext, hasUI: boolean) => void;
316
319
 
317
320
  readonly #btwController: BtwController;
321
+ readonly #omfgController: OmfgController;
318
322
  readonly #commandController: CommandController;
319
323
  readonly #todoCommandController: TodoCommandController;
320
324
  readonly #eventController: EventController;
@@ -368,6 +372,7 @@ export class InteractiveMode implements InteractiveModeContext {
368
372
  this.statusContainer = new Container();
369
373
  this.todoContainer = new Container();
370
374
  this.btwContainer = new Container();
375
+ this.omfgContainer = new Container();
371
376
  this.editor = new CustomEditor(getEditorTheme());
372
377
  this.editor.setUseTerminalCursor(this.ui.getShowHardwareCursor());
373
378
  this.editor.setAutocompleteMaxVisible(settings.get("autocompleteMaxVisible"));
@@ -429,6 +434,7 @@ export class InteractiveMode implements InteractiveModeContext {
429
434
 
430
435
  this.#uiHelpers = new UiHelpers(this);
431
436
  this.#btwController = new BtwController(this);
437
+ this.#omfgController = new OmfgController(this);
432
438
  this.#extensionUiController = new ExtensionUiController(this);
433
439
  this.#eventController = new EventController(this);
434
440
  this.#commandController = new CommandController(this);
@@ -526,6 +532,7 @@ export class InteractiveMode implements InteractiveModeContext {
526
532
  this.ui.addChild(this.statusContainer);
527
533
  this.ui.addChild(this.todoContainer);
528
534
  this.ui.addChild(this.btwContainer);
535
+ this.ui.addChild(this.omfgContainer);
529
536
  this.ui.addChild(this.statusLine); // Only renders hook statuses (main status in editor border)
530
537
  this.ui.addChild(this.hookWidgetContainerAbove);
531
538
  this.ui.addChild(this.editorContainer);
@@ -2034,16 +2041,20 @@ export class InteractiveMode implements InteractiveModeContext {
2034
2041
  : "Approve and keep context";
2035
2042
 
2036
2043
  // Model-tier slider: let the operator pick which configured role model
2037
- // (smol/default/slow/…) executes the approved plan. Left/right move it from
2038
- // any list position. Hidden when fewer than two role models resolve a lone
2039
- // tier is no choice. `selectedTierIndex` tracks the live slider position.
2044
+ // (smol/default/slow/…) executes the approved plan. The slider always starts
2045
+ // on the `default` tier so execution defaults to the default model no matter
2046
+ // which model drove the planning conversation. Left/right move it from there;
2047
+ // hidden when fewer than two role models resolve — a lone tier is no choice.
2048
+ // `selectedTierIndex` tracks the live slider position.
2040
2049
  const cycle = this.session.getRoleModelCycle(this.session.settings.get("cycleOrder"));
2041
- let selectedTierIndex = cycle?.currentIndex ?? 0;
2050
+ const defaultTierIndex = cycle ? cycle.models.findIndex(entry => entry.role === "default") : -1;
2051
+ const startTierIndex = defaultTierIndex >= 0 ? defaultTierIndex : (cycle?.currentIndex ?? 0);
2052
+ let selectedTierIndex = startTierIndex;
2042
2053
  const slider: HookSelectorSlider | undefined =
2043
2054
  cycle && cycle.models.length > 1
2044
2055
  ? {
2045
2056
  caption: "continue with",
2046
- index: cycle.currentIndex,
2057
+ index: startTierIndex,
2047
2058
  segments: cycle.models.map(entry => ({
2048
2059
  label: entry.role,
2049
2060
  color: MODEL_ROLES[entry.role as ModelRole]?.color,
@@ -2080,6 +2091,9 @@ export class InteractiveMode implements InteractiveModeContext {
2080
2091
  // applying the slider choice any earlier would be silently reverted —
2081
2092
  // the bug that made "continue with slow" keep executing on the default
2082
2093
  // model. Deferred application also survives newSession()/compaction.
2094
+ // `cycle.currentIndex` is exactly that restored model, so any chosen tier
2095
+ // differing from it needs an explicit executionModel — this also covers
2096
+ // leaving the slider on its `default` anchor while planning ran elsewhere.
2083
2097
  const executionModel =
2084
2098
  cycle && selectedTierIndex !== cycle.currentIndex ? cycle.models[selectedTierIndex] : undefined;
2085
2099
  await this.#approvePlan(latestPlanContent, {
@@ -2211,6 +2225,7 @@ export class InteractiveMode implements InteractiveModeContext {
2211
2225
  logger.warn("Failed to save session draft", { error: String(err) });
2212
2226
  }
2213
2227
  this.#btwController.dispose();
2228
+ this.#omfgController.dispose();
2214
2229
 
2215
2230
  // Emit shutdown event to hooks
2216
2231
  await this.session.dispose();
@@ -2532,6 +2547,7 @@ export class InteractiveMode implements InteractiveModeContext {
2532
2547
 
2533
2548
  #prepareSessionSwitch(): void {
2534
2549
  this.#btwController.dispose();
2550
+ this.#omfgController.dispose();
2535
2551
  this.#extensionUiController.clearExtensionTerminalInputListeners();
2536
2552
  this.#planReviewContainer = undefined;
2537
2553
  }
@@ -2548,6 +2564,7 @@ export class InteractiveMode implements InteractiveModeContext {
2548
2564
 
2549
2565
  handleForkCommand(): Promise<void> {
2550
2566
  this.#btwController.dispose();
2567
+ this.#omfgController.dispose();
2551
2568
  return this.#commandController.handleForkCommand();
2552
2569
  }
2553
2570
 
@@ -2683,6 +2700,10 @@ export class InteractiveMode implements InteractiveModeContext {
2683
2700
  return this.#commandController.handleHandoffCommand(customInstructions);
2684
2701
  }
2685
2702
 
2703
+ handleShakeCommand(mode: ShakeMode): Promise<void> {
2704
+ return this.#commandController.handleShakeCommand(mode);
2705
+ }
2706
+
2686
2707
  executeCompaction(
2687
2708
  customInstructionsOrOptions?: string | CompactOptions,
2688
2709
  isAuto?: boolean,
@@ -2733,6 +2754,7 @@ export class InteractiveMode implements InteractiveModeContext {
2733
2754
 
2734
2755
  handleResumeSession(sessionPath: string): Promise<void> {
2735
2756
  this.#btwController.dispose();
2757
+ this.#omfgController.dispose();
2736
2758
  this.resetObserverRegistry();
2737
2759
  return this.#selectorController.handleResumeSession(sessionPath);
2738
2760
  }
@@ -2786,12 +2808,24 @@ export class InteractiveMode implements InteractiveModeContext {
2786
2808
  return this.#btwController.handleEscape();
2787
2809
  }
2788
2810
 
2811
+ handleOmfgCommand(complaint: string): Promise<void> {
2812
+ return this.#omfgController.start(complaint);
2813
+ }
2814
+
2815
+ hasActiveOmfg(): boolean {
2816
+ return this.#omfgController.hasActiveRequest();
2817
+ }
2818
+
2819
+ handleOmfgEscape(): boolean {
2820
+ return this.#omfgController.handleEscape();
2821
+ }
2822
+
2789
2823
  cycleThinkingLevel(): void {
2790
2824
  this.#inputController.cycleThinkingLevel();
2791
2825
  }
2792
2826
 
2793
- cycleRoleModel(options?: { temporary?: boolean }): Promise<void> {
2794
- return this.#inputController.cycleRoleModel(options);
2827
+ cycleRoleModel(direction?: "forward" | "backward"): Promise<void> {
2828
+ return this.#inputController.cycleRoleModel(direction);
2795
2829
  }
2796
2830
 
2797
2831
  toggleToolOutputExpansion(): void {
@@ -0,0 +1,20 @@
1
+ import { highlightOrchestrate } from "./orchestrate";
2
+ import { highlightUltrathink } from "./ultrathink";
3
+ import { highlightWorkflow } from "./workflow";
4
+
5
+ /**
6
+ * Gradient-highlight every magic keyword ("ultrathink", "orchestrate",
7
+ * "workflow") that appears as standalone prose, skipping any occurrence inside a
8
+ * code block, inline code span, or XML/HTML section. Each highlighter paints its
9
+ * own keyword with its own gradient, so chaining is order-independent — the
10
+ * earlier passes only inject zero-width SGR escapes (no backticks or angle
11
+ * brackets), which never confuse the later passes' markdown masking.
12
+ *
13
+ * `resetTo` is the SGR foreground sequence restored after each painted keyword;
14
+ * pass the surrounding text color when decorating already-colored content (e.g.
15
+ * a themed message bubble) so the gradient does not bleed into the rest of the
16
+ * line. Defaults to a plain foreground reset for default-colored editor text.
17
+ */
18
+ export function highlightMagicKeywords(text: string, resetTo?: string): string {
19
+ return highlightWorkflow(highlightOrchestrate(highlightUltrathink(text, resetTo), resetTo), resetTo);
20
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Markdown structure awareness for the magic-keyword affordances
3
+ * ("ultrathink"/"orchestrate"/"workflow").
4
+ *
5
+ * Keyword detection and editor/transcript highlighting must fire only on prose
6
+ * the user is actually addressing to the model — never on a word that happens to
7
+ * live inside a fenced code block, an inline code span, or an HTML/XML section.
8
+ * {@link maskNonProse} returns a length-preserving copy of the text where every
9
+ * such region is blanked to spaces, so a word-bounded match run against the mask
10
+ * never lands inside code/markup while its indices still address the original
11
+ * text for painting.
12
+ */
13
+
14
+ // Tag/element name: HTML5/XML start char + name chars. Sticky so we can probe at
15
+ // a precise offset without slicing.
16
+ const TAG_NAME = /[A-Za-z][A-Za-z0-9-]*/y;
17
+
18
+ // A line that opens or closes a fenced code block: up to 3 leading spaces then a
19
+ // run of >=3 backticks or tildes.
20
+ const FENCE = /^( {0,3})([`~]{3,})/;
21
+
22
+ /** Index just past the run of backticks beginning at `i`. */
23
+ function backtickRunEnd(text: string, i: number, n: number): number {
24
+ let j = i;
25
+ while (j < n && text[j] === "`") j++;
26
+ return j;
27
+ }
28
+
29
+ /**
30
+ * Find the closing backtick run that matches an opening run of `runLen`
31
+ * backticks, scanning from `from`. Returns the index just past the closing run,
32
+ * or -1 when no run of the exact length exists (an unmatched run is literal text,
33
+ * not a code span). Already-masked positions (fenced code) are skipped.
34
+ */
35
+ function findBacktickClose(text: string, from: number, n: number, runLen: number, masked: Uint8Array): number {
36
+ let k = from;
37
+ while (k < n) {
38
+ if (masked[k]) {
39
+ k++;
40
+ continue;
41
+ }
42
+ if (text[k] === "`") {
43
+ const e = backtickRunEnd(text, k, n);
44
+ if (e - k === runLen) return e;
45
+ k = e;
46
+ continue;
47
+ }
48
+ k++;
49
+ }
50
+ return -1;
51
+ }
52
+
53
+ /**
54
+ * Index of the `>` that closes a tag whose attributes begin at `j`, honoring
55
+ * quoted attribute values. Returns -1 when the tag is malformed (a new `<`
56
+ * appears first, or there is no `>`), so callers can treat the `<` as literal.
57
+ */
58
+ function findTagEnd(text: string, j: number, n: number): number {
59
+ let quote = "";
60
+ for (let k = j; k < n; k++) {
61
+ const ch = text[k];
62
+ if (quote) {
63
+ if (ch === quote) quote = "";
64
+ continue;
65
+ }
66
+ if (ch === '"' || ch === "'") {
67
+ quote = ch;
68
+ continue;
69
+ }
70
+ if (ch === ">") return k;
71
+ if (ch === "<") return -1;
72
+ }
73
+ return -1;
74
+ }
75
+
76
+ /**
77
+ * Locate the `</name>` that balances an opening `<name>` at `start`, counting
78
+ * nested same-name tags. Returns the index just past the matching close tag's
79
+ * `>`, or -1 when the section is never closed (so callers mask only the opening
80
+ * tag rather than swallowing the rest of the document).
81
+ */
82
+ function findMatchingClose(text: string, start: number, n: number, name: string, masked: Uint8Array): number {
83
+ const lname = name.toLowerCase();
84
+ let depth = 1;
85
+ let k = start;
86
+ while (k < n) {
87
+ if (masked[k] || text[k] !== "<") {
88
+ k++;
89
+ continue;
90
+ }
91
+ let m = k + 1;
92
+ let isClose = false;
93
+ if (text[m] === "/") {
94
+ isClose = true;
95
+ m++;
96
+ }
97
+ TAG_NAME.lastIndex = m;
98
+ const nm = TAG_NAME.exec(text);
99
+ if (!nm) {
100
+ k++;
101
+ continue;
102
+ }
103
+ const gt = findTagEnd(text, TAG_NAME.lastIndex, n);
104
+ if (gt < 0) {
105
+ k++;
106
+ continue;
107
+ }
108
+ if (nm[0].toLowerCase() === lname) {
109
+ if (isClose) {
110
+ depth--;
111
+ if (depth === 0) return gt + 1;
112
+ } else if (text[gt - 1] !== "/") {
113
+ depth++;
114
+ }
115
+ }
116
+ k = gt + 1;
117
+ }
118
+ return -1;
119
+ }
120
+
121
+ /**
122
+ * Mask the HTML/XML construct beginning at `<` (index `i`): an HTML comment, a
123
+ * self-closing/closing tag (the tag alone), or an opening tag together with the
124
+ * content through its matching close tag. Returns the index just past the masked
125
+ * region, or `i` when the `<` does not begin a tag (e.g. a stray less-than).
126
+ */
127
+ function maskTagAt(text: string, i: number, n: number, masked: Uint8Array): number {
128
+ if (text.startsWith("<!--", i)) {
129
+ const end = text.indexOf("-->", i + 4);
130
+ const stop = end < 0 ? n : end + 3;
131
+ for (let p = i; p < stop; p++) masked[p] = 1;
132
+ return stop;
133
+ }
134
+ let j = i + 1;
135
+ let closing = false;
136
+ if (text[j] === "/") {
137
+ closing = true;
138
+ j++;
139
+ }
140
+ TAG_NAME.lastIndex = j;
141
+ const nm = TAG_NAME.exec(text);
142
+ if (!nm) return i;
143
+ const gt = findTagEnd(text, TAG_NAME.lastIndex, n);
144
+ if (gt < 0) return i;
145
+ const tagEnd = gt + 1;
146
+ const selfClosing = text[gt - 1] === "/";
147
+ for (let p = i; p < tagEnd; p++) masked[p] = 1;
148
+ if (closing || selfClosing) return tagEnd;
149
+ const close = findMatchingClose(text, tagEnd, n, nm[0], masked);
150
+ if (close < 0) return tagEnd;
151
+ for (let p = tagEnd; p < close; p++) masked[p] = 1;
152
+ return close;
153
+ }
154
+
155
+ /**
156
+ * Return a copy of `text` with identical length (indices map 1:1) where every
157
+ * character inside a non-prose region is replaced by a space. Non-prose regions
158
+ * are markdown fenced code blocks, inline code spans, and HTML/XML tags together
159
+ * with the content they enclose. Newlines are preserved. Text with no construct
160
+ * that could open such a region is returned unchanged.
161
+ */
162
+ export function maskNonProse(text: string): string {
163
+ if (!text.includes("`") && !text.includes("<") && !text.includes("~~~")) {
164
+ return text;
165
+ }
166
+ const n = text.length;
167
+ const masked = new Uint8Array(n);
168
+
169
+ // Phase 1: fenced code blocks, line by line.
170
+ let fenceChar = "";
171
+ let fenceLen = 0;
172
+ let lineStart = 0;
173
+ while (lineStart <= n) {
174
+ let nl = text.indexOf("\n", lineStart);
175
+ if (nl < 0) nl = n;
176
+ const line = text.slice(lineStart, nl);
177
+ const open = FENCE.exec(line);
178
+ if (fenceChar) {
179
+ for (let p = lineStart; p < nl; p++) masked[p] = 1;
180
+ // A closing fence is the same char, at least as long, with nothing else on the line.
181
+ if (
182
+ open &&
183
+ open[2]![0] === fenceChar &&
184
+ open[2]!.length >= fenceLen &&
185
+ line.slice(open[1]!.length + open[2]!.length).trim() === ""
186
+ ) {
187
+ fenceChar = "";
188
+ fenceLen = 0;
189
+ }
190
+ } else if (open) {
191
+ const marker = open[2]!;
192
+ const ch = marker[0]!;
193
+ // A backtick fence's info string may not contain a backtick.
194
+ if (!(ch === "`" && line.slice(open[1]!.length + marker.length).includes("`"))) {
195
+ fenceChar = ch;
196
+ fenceLen = marker.length;
197
+ for (let p = lineStart; p < nl; p++) masked[p] = 1;
198
+ }
199
+ }
200
+ if (nl === n) break;
201
+ lineStart = nl + 1;
202
+ }
203
+
204
+ // Phase 2: inline code spans and HTML/XML, over not-yet-masked regions.
205
+ let i = 0;
206
+ while (i < n) {
207
+ if (masked[i]) {
208
+ i++;
209
+ continue;
210
+ }
211
+ const c = text[i];
212
+ if (c === "`") {
213
+ const runEnd = backtickRunEnd(text, i, n);
214
+ const close = findBacktickClose(text, runEnd, n, runEnd - i, masked);
215
+ if (close >= 0) {
216
+ for (let p = i; p < close; p++) masked[p] = 1;
217
+ i = close;
218
+ } else {
219
+ i = runEnd;
220
+ }
221
+ continue;
222
+ }
223
+ if (c === "<") {
224
+ const end = maskTagAt(text, i, n, masked);
225
+ i = end > i ? end : i + 1;
226
+ continue;
227
+ }
228
+ i++;
229
+ }
230
+
231
+ const arr = text.split("");
232
+ for (let p = 0; p < n; p++) {
233
+ if (masked[p] && arr[p] !== "\n") arr[p] = " ";
234
+ }
235
+ return arr.join("");
236
+ }
237
+
238
+ /**
239
+ * Whether `text` contains a standalone keyword match (per the non-global,
240
+ * word-bounded `word` regex) that lives in prose rather than inside a code
241
+ * block, inline code span, or HTML/XML section. `word` MUST be non-global so
242
+ * `.test` stays stateless.
243
+ */
244
+ export function keywordInProse(text: string, word: RegExp): boolean {
245
+ if (!word.test(text)) return false;
246
+ return word.test(maskNonProse(text));
247
+ }
@@ -1,5 +1,6 @@
1
1
  import orchestrateNotice from "../prompts/system/orchestrate-notice.md" with { type: "text" };
2
- import { createGradientHighlighter } from "./gradient-highlight";
2
+ import { createGradientHighlighter, type KeywordHighlighter } from "./gradient-highlight";
3
+ import { keywordInProse } from "./markdown-prose";
3
4
 
4
5
  /**
5
6
  * "orchestrate" keyword support.
@@ -7,20 +8,25 @@ import { createGradientHighlighter } from "./gradient-highlight";
7
8
  * Typing the standalone word in the input editor paints it with a cool
8
9
  * teal→violet gradient ({@link highlightOrchestrate}); submitting a message that
9
10
  * mentions it appends a hidden {@link ORCHESTRATE_NOTICE} that switches the model
10
- * into multi-agent orchestration mode. Matching is word-bounded and
11
- * case-insensitive, so "orchestrated"/"orchestrating" never trigger either
12
- * behavior. Replaces the former `/orchestrate` slash command.
11
+ * into multi-agent orchestration mode. Matching is whitespace-delimited and
12
+ * case-sensitive (lowercase only), so "orchestrated", "Orchestrate", or a path
13
+ * like "orchestrate.ts" never trigger either behavior. Replaces the former
14
+ * `/orchestrate` slash command.
13
15
  */
14
16
 
15
- // Detection: standalone keyword, any case. Non-global so `.test` stays stateless.
16
- const ORCHESTRATE_WORD = /\borchestrate\b/i;
17
+ // Detection: lowercase keyword flanked by whitespace or a string edge. Non-global so `.test` stays stateless.
18
+ const ORCHESTRATE_WORD = /(?<!\S)orchestrate(?!\S)/;
17
19
 
18
20
  /** Hidden system notice appended after a user message that mentions "orchestrate". */
19
21
  export const ORCHESTRATE_NOTICE: string = orchestrateNotice.trim();
20
22
 
21
- /** Whether `text` contains the standalone keyword "orchestrate" (any case). */
23
+ /**
24
+ * Whether `text` contains the standalone keyword "orchestrate" (lowercase,
25
+ * whitespace-delimited) in prose — never inside a code block, inline code span,
26
+ * or XML/HTML section.
27
+ */
22
28
  export function containsOrchestrate(text: string): boolean {
23
- return ORCHESTRATE_WORD.test(text);
29
+ return keywordInProse(text, ORCHESTRATE_WORD);
24
30
  }
25
31
 
26
32
  /**
@@ -28,9 +34,9 @@ export function containsOrchestrate(text: string): boolean {
28
34
  * cool teal→violet gradient (hue 150..280), visually distinct from ultrathink's
29
35
  * full-spectrum rainbow.
30
36
  */
31
- export const highlightOrchestrate: (text: string) => string = createGradientHighlighter({
32
- probe: /orchestrate/i,
33
- highlight: /\borchestrate\b/gi,
37
+ export const highlightOrchestrate: KeywordHighlighter = createGradientHighlighter({
38
+ probe: /orchestrate/,
39
+ highlight: /(?<!\S)orchestrate(?!\S)/g,
34
40
  stops: 14,
35
41
  hue: t => 150 + t * 130,
36
42
  });
@@ -1,3 +1,4 @@
1
+ import { stripVTControlCharacters } from "node:util";
1
2
  import type { TabBarTheme } from "@oh-my-pi/pi-tui";
2
3
  import { theme } from "./theme/theme";
3
4
 
@@ -5,18 +6,9 @@ import { theme } from "./theme/theme";
5
6
  // Text Sanitization
6
7
  // ═══════════════════════════════════════════════════════════════════════════
7
8
 
8
- const ANSI_OSC_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x9d[^\x07\x9c]*(?:\x07|\x9c)/g;
9
- const ANSI_STRING_RE = /\x1b(?:P|_|\^)[\s\S]*?\x1b\\|[\x90\x9e\x9f][\s\S]*?\x9c/g;
10
- const ANSI_CSI_RE = /\x1b\[[0-?]*[ -/]*[@-~]|\x9b[0-?]*[ -/]*[@-~]/g;
11
- const ANSI_SINGLE_RE = /\x1b[@-Z\\-_]/g;
12
-
13
- /** Sanitize text for display in a single-line status. Strips ANSI escape sequences, C0/C1 control characters, collapses whitespace, trims. */
9
+ /** Sanitize text for display in a single-line status. Strips ANSI/VT escape sequences, maps remaining C0/C1 control characters to spaces, collapses whitespace, trims. */
14
10
  export function sanitizeStatusText(text: string): string {
15
- return text
16
- .replace(ANSI_OSC_RE, "")
17
- .replace(ANSI_STRING_RE, "")
18
- .replace(ANSI_CSI_RE, "")
19
- .replace(ANSI_SINGLE_RE, "")
11
+ return stripVTControlCharacters(text)
20
12
  .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
21
13
  .replace(/ +/g, " ")
22
14
  .trim();
@@ -132,6 +132,7 @@ export type SymbolKey =
132
132
  | "thinking.medium"
133
133
  | "thinking.high"
134
134
  | "thinking.xhigh"
135
+ | "thinking.autoPending"
135
136
  // Checkboxes
136
137
  | "checkbox.checked"
137
138
  | "checkbox.unchecked"
@@ -297,6 +298,7 @@ const UNICODE_SYMBOLS: SymbolMap = {
297
298
  "thinking.medium": "◒ med",
298
299
  "thinking.high": "◕ high",
299
300
  "thinking.xhigh": "◉ xhigh",
301
+ "thinking.autoPending": "▣?",
300
302
  // Checkboxes
301
303
  "checkbox.checked": "☑",
302
304
  "checkbox.unchecked": "☐",
@@ -550,6 +552,8 @@ const NERD_SYMBOLS: SymbolMap = {
550
552
  "thinking.high": "\u{F111} high",
551
553
  // pick: 🧠 xhi | alt:  xhi  xhi
552
554
  "thinking.xhigh": "\u{F06D} xhi",
555
+ // pick: 󰞋 (nf-md-help_box) | alt:  [?]
556
+ "thinking.autoPending": "\u{f078b}",
553
557
  // Checkboxes
554
558
  // pick:  | alt:  
555
559
  "checkbox.checked": "\uf14a",
@@ -723,6 +727,7 @@ const ASCII_SYMBOLS: SymbolMap = {
723
727
  "thinking.medium": "[med]",
724
728
  "thinking.high": "[high]",
725
729
  "thinking.xhigh": "[xhi]",
730
+ "thinking.autoPending": "[?]",
726
731
  // Checkboxes
727
732
  "checkbox.checked": "[x]",
728
733
  "checkbox.unchecked": "[ ]",
@@ -1518,6 +1523,7 @@ export class Theme {
1518
1523
  medium: this.#symbols["thinking.medium"],
1519
1524
  high: this.#symbols["thinking.high"],
1520
1525
  xhigh: this.#symbols["thinking.xhigh"],
1526
+ autoPending: this.#symbols["thinking.autoPending"],
1521
1527
  };
1522
1528
  }
1523
1529
 
@@ -0,0 +1,31 @@
1
+ /**
2
+ * "+Nk" turn token-budget directive.
3
+ *
4
+ * A standalone `+<number>[k|m]` token in the user's message sets a per-turn
5
+ * output-token budget surfaced by the `eval` `budget` helper. By default it is
6
+ * ADVISORY — the model self-limits via `budget.remaining()`. Append `!`
7
+ * (`+500k!`) to make it a HARD ceiling: eval `agent()` refuses to spawn once the
8
+ * turn's spend reaches it. Matching is anchored to token boundaries so it does
9
+ * not fire on prices or version strings embedded in prose.
10
+ */
11
+
12
+ // Number, optional k/m multiplier, optional `!` hard marker, bounded by whitespace/string edges.
13
+ const TURN_BUDGET = /(?:^|\s)\+(\d+(?:\.\d+)?)([km])?(!)?(?=\s|$)/i;
14
+
15
+ export interface TurnBudget {
16
+ /** Output-token ceiling for the turn. */
17
+ total: number;
18
+ /** Whether the ceiling is enforced (eval `agent()` throws past it) vs advisory. */
19
+ hard: boolean;
20
+ }
21
+
22
+ /** Parse a `+Nk`/`+N`/`+Nm`(`!`) turn-budget directive from `text`, or null when absent. */
23
+ export function parseTurnBudget(text: string): TurnBudget | null {
24
+ const match = TURN_BUDGET.exec(text);
25
+ if (!match) return null;
26
+ const value = Number(match[1]);
27
+ if (!Number.isFinite(value) || value <= 0) return null;
28
+ const unit = match[2]?.toLowerCase();
29
+ const multiplier = unit === "k" ? 1_000 : unit === "m" ? 1_000_000 : 1;
30
+ return { total: Math.round(value * multiplier), hard: match[3] === "!" };
31
+ }
@@ -16,6 +16,7 @@ import type { PlanApprovalDetails } from "../plan-mode/approved-plan";
16
16
  import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
17
17
  import type { HistoryStorage } from "../session/history-storage";
18
18
  import type { SessionContext, SessionManager } from "../session/session-manager";
19
+ import type { ShakeMode } from "../session/shake-types";
19
20
  import type { LspStartupServerInfo } from "../tools";
20
21
  import type { AssistantMessageComponent } from "./components/assistant-message";
21
22
  import type { BashExecutionComponent } from "./components/bash-execution";
@@ -70,6 +71,7 @@ export interface InteractiveModeContext {
70
71
  statusContainer: Container;
71
72
  todoContainer: Container;
72
73
  btwContainer: Container;
74
+ omfgContainer: Container;
73
75
  editor: CustomEditor;
74
76
  editorContainer: Container;
75
77
  hookWidgetContainerAbove: Container;
@@ -228,6 +230,7 @@ export interface InteractiveModeContext {
228
230
  handleSSHCommand(text: string): Promise<void>;
229
231
  handleCompactCommand(customInstructions?: string): Promise<CompactionOutcome>;
230
232
  handleHandoffCommand(customInstructions?: string): Promise<void>;
233
+ handleShakeCommand(mode: ShakeMode): Promise<void>;
231
234
  handleMoveCommand(targetPath: string): Promise<void>;
232
235
  handleRenameCommand(title: string): Promise<void>;
233
236
  handleMemoryCommand(text: string): Promise<void>;
@@ -267,8 +270,11 @@ export interface InteractiveModeContext {
267
270
  handleBtwCommand(question: string): Promise<void>;
268
271
  hasActiveBtw(): boolean;
269
272
  handleBtwEscape(): boolean;
273
+ handleOmfgCommand(complaint: string): Promise<void>;
274
+ hasActiveOmfg(): boolean;
275
+ handleOmfgEscape(): boolean;
270
276
  cycleThinkingLevel(): void;
271
- cycleRoleModel(options?: { temporary?: boolean }): Promise<void>;
277
+ cycleRoleModel(direction?: "forward" | "backward"): Promise<void>;
272
278
  toggleToolOutputExpansion(): void;
273
279
  setToolsExpanded(expanded: boolean): void;
274
280
  toggleThinkingBlockVisibility(): void;