@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
@@ -45,15 +45,6 @@ function ensureInvalidate(component: unknown): Component {
45
45
  return c as Component;
46
46
  }
47
47
 
48
- function cloneToolArgs<T>(args: T): T {
49
- if (args === null || args === undefined) return args;
50
- try {
51
- return structuredClone(args);
52
- } catch {
53
- return args;
54
- }
55
- }
56
-
57
48
  /**
58
49
  * Drop trailing removal/hunk-header lines that appear in a streaming diff
59
50
  * before the matching `+added` lines have arrived. Without this, a partial
@@ -107,6 +98,27 @@ function resolveEditModeForTool(toolName: string, tool: AgentTool | undefined):
107
98
  return (tool as { mode?: EditMode } | undefined)?.mode;
108
99
  }
109
100
 
101
+ function rawTextInputFromPartialJson(partialJson: unknown): string | undefined {
102
+ if (typeof partialJson !== "string") return undefined;
103
+ if (partialJson.length === 0) return undefined;
104
+ const trimmed = partialJson.trimStart();
105
+ if (trimmed.length === 0) return undefined;
106
+ const first = trimmed[0];
107
+ // Function-tool arguments stream as JSON. Custom/free-form tools stream raw
108
+ // text in the same transport field; only the raw form is a valid fallback for
109
+ // the conventional `input` parameter.
110
+ if (first === "{" || first === "[" || first === '"') return undefined;
111
+ return partialJson;
112
+ }
113
+
114
+ function getArgsWithStreamedTextInput(args: unknown): unknown {
115
+ if (args == null || typeof args !== "object") return args;
116
+ const record = args as Record<string, unknown>;
117
+ if (typeof record.input === "string") return args;
118
+ const input = rawTextInputFromPartialJson(record.__partialJson);
119
+ return input === undefined ? args : { ...record, input };
120
+ }
121
+
110
122
  export interface ToolExecutionOptions {
111
123
  snapshots?: SnapshotStore;
112
124
  showImages?: boolean; // default: true (only used if terminal supports images)
@@ -198,7 +210,7 @@ export class ToolExecutionComponent extends Container {
198
210
  this.#tool = tool;
199
211
  this.#ui = ui;
200
212
  this.#cwd = cwd;
201
- this.#args = cloneToolArgs(args);
213
+ this.#args = args;
202
214
 
203
215
  this.addChild(new Spacer(1));
204
216
 
@@ -222,7 +234,12 @@ export class ToolExecutionComponent extends Container {
222
234
  }
223
235
 
224
236
  updateArgs(args: any, _toolCallId?: string): void {
225
- this.#args = cloneToolArgs(args);
237
+ // Reference-equality short-circuit before any further work. Callers
238
+ // always allocate a new arg object on each streamed delta (see
239
+ // event-controller.ts and ui-helpers.ts), so a same-reference assignment
240
+ // signals "nothing meaningful changed" and the renderer can skip.
241
+ if (args === this.#args) return;
242
+ this.#args = args;
226
243
  this.#updateSpinnerAnimation();
227
244
  void this.#runPreviewDiff();
228
245
  this.#updateDisplay();
@@ -247,12 +264,13 @@ export class ToolExecutionComponent extends Container {
247
264
  const args = this.#args;
248
265
  if (args == null || typeof args !== "object") return;
249
266
 
250
- const partialJson = (args as { __partialJson?: string }).__partialJson;
267
+ const previewArgs = getArgsWithStreamedTextInput(args);
268
+ const partialJson = (previewArgs as { __partialJson?: string }).__partialJson;
251
269
  let effectiveArgs: unknown;
252
270
  try {
253
- effectiveArgs = strategy.extractCompleteEdits(args, partialJson);
271
+ effectiveArgs = strategy.extractCompleteEdits(previewArgs, partialJson);
254
272
  } catch {
255
- effectiveArgs = args;
273
+ effectiveArgs = previewArgs;
256
274
  }
257
275
 
258
276
  // Coalesce duplicate computes for identical args. The key pairs the
@@ -717,20 +735,21 @@ export class ToolExecutionComponent extends Container {
717
735
  }
718
736
 
719
737
  #getCallArgsForRender(): any {
738
+ const renderArgs = getArgsWithStreamedTextInput(this.#args);
720
739
  if (!isEditLikeToolName(this.#toolName)) {
721
- return this.#args;
740
+ return renderArgs;
722
741
  }
723
742
  const previews = this.#editDiffPreview;
724
743
  if (!previews || previews.length === 0) {
725
- return this.#args;
744
+ return renderArgs;
726
745
  }
727
746
  // Single-file previews feed the existing `previewDiff` channel consumed
728
747
  // by `formatStreamingDiff` in the renderer.
729
748
  const first = previews[0];
730
749
  if (!first?.diff) {
731
- return this.#args;
750
+ return renderArgs;
732
751
  }
733
- return { ...(this.#args as Record<string, unknown>), previewDiff: first.diff };
752
+ return { ...(renderArgs as Record<string, unknown>), previewDiff: first.diff };
734
753
  }
735
754
 
736
755
  /**
@@ -781,7 +800,7 @@ export class ToolExecutionComponent extends Container {
781
800
  if (!previews?.some(preview => preview.diff)) {
782
801
  const editMode = this.#editMode;
783
802
  const strategy = editMode ? EDIT_MODE_STRATEGIES[editMode] : undefined;
784
- const fallback = strategy?.renderStreamingFallback(this.#args, theme);
803
+ const fallback = strategy?.renderStreamingFallback(getArgsWithStreamedTextInput(this.#args), theme);
785
804
  if (fallback) context.editStreamingFallback = fallback;
786
805
  }
787
806
  context.renderDiff = renderDiff;
@@ -3,6 +3,7 @@ import {
3
3
  type Component,
4
4
  Container,
5
5
  extractPrintableText,
6
+ fuzzyMatch,
6
7
  Input,
7
8
  matchesKey,
8
9
  Spacer,
@@ -325,10 +326,10 @@ class TreeList implements Component {
325
326
 
326
327
  if (!passesFilter) return false;
327
328
 
328
- // Apply search filter
329
+ // Apply fuzzy search filter
329
330
  if (searchTokens.length > 0) {
330
- const nodeText = this.#getSearchableText(flatNode.node).toLowerCase();
331
- return searchTokens.every(token => nodeText.includes(token));
331
+ const nodeText = this.#getSearchableText(flatNode.node);
332
+ return searchTokens.every(token => fuzzyMatch(token, nodeText).matches);
332
333
  }
333
334
 
334
335
  return true;
@@ -1,4 +1,13 @@
1
- import { type Component, Container, matchesKey, Spacer, Text, truncateToWidth } from "@oh-my-pi/pi-tui";
1
+ import {
2
+ type Component,
3
+ Container,
4
+ extractPrintableText,
5
+ fuzzyFilter,
6
+ matchesKey,
7
+ Spacer,
8
+ Text,
9
+ truncateToWidth,
10
+ } from "@oh-my-pi/pi-tui";
2
11
  import { theme } from "../../modes/theme/theme";
3
12
  import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers";
4
13
  import { DynamicBorder } from "./dynamic-border";
@@ -13,6 +22,8 @@ interface UserMessageItem {
13
22
  * Custom user message list component with selection
14
23
  */
15
24
  class UserMessageList implements Component {
25
+ #filteredMessages: UserMessageItem[];
26
+ #searchQuery = "";
16
27
  #selectedIndex: number = 0;
17
28
  onSelect?: (entryId: string) => void;
18
29
  onCancel?: () => void;
@@ -20,14 +31,60 @@ class UserMessageList implements Component {
20
31
 
21
32
  constructor(private readonly messages: UserMessageItem[]) {
22
33
  // Store messages in chronological order (oldest to newest)
34
+ this.#filteredMessages = messages;
23
35
  // Start with the last (most recent) message selected
24
- this.#selectedIndex = Math.max(0, this.messages.length - 1);
36
+ this.#selectedIndex = Math.max(0, this.#filteredMessages.length - 1);
25
37
  }
26
38
 
27
39
  invalidate(): void {
28
40
  // No cached state to invalidate currently
29
41
  }
30
42
 
43
+ #isSearchEnabled(): boolean {
44
+ return this.messages.length > this.#maxVisible;
45
+ }
46
+
47
+ #shouldRenderSearchStatus(): boolean {
48
+ return this.#isSearchEnabled() || this.#searchQuery.length > 0;
49
+ }
50
+
51
+ #renderStatusLine(total: number): string {
52
+ const selectedCount = total === 0 ? 0 : this.#selectedIndex + 1;
53
+ const count =
54
+ this.#searchQuery.trim() && total !== this.messages.length
55
+ ? `${selectedCount}/${total} of ${this.messages.length}`
56
+ : `${selectedCount}/${total}`;
57
+ const suffix = this.#searchQuery.trim() ? ` Search: ${this.#searchQuery}` : " Type to search";
58
+ return theme.fg("muted", ` (${count})${suffix}`);
59
+ }
60
+
61
+ #setSearchQuery(query: string): void {
62
+ this.#searchQuery = query;
63
+ this.#filteredMessages = query.trim()
64
+ ? fuzzyFilter(this.messages, query, message => `${message.text} ${message.timestamp ?? ""}`)
65
+ : this.messages;
66
+ this.#selectedIndex = query.trim() ? 0 : Math.max(0, this.#filteredMessages.length - 1);
67
+ }
68
+
69
+ #handleSearchInput(keyData: string): boolean {
70
+ if (!this.#isSearchEnabled()) return false;
71
+
72
+ if (matchesKey(keyData, "backspace")) {
73
+ if (this.#searchQuery.length === 0) return false;
74
+ const chars = [...this.#searchQuery];
75
+ chars.pop();
76
+ this.#setSearchQuery(chars.join(""));
77
+ return true;
78
+ }
79
+
80
+ const printableText = extractPrintableText(keyData);
81
+ if (printableText === undefined) return false;
82
+ if (this.#searchQuery.length === 0 && printableText.trim().length === 0) return false;
83
+
84
+ this.#setSearchQuery(this.#searchQuery + printableText);
85
+ return true;
86
+ }
87
+
31
88
  render(width: number): string[] {
32
89
  const lines: string[] = [];
33
90
 
@@ -36,16 +93,19 @@ class UserMessageList implements Component {
36
93
  return lines;
37
94
  }
38
95
 
96
+ const total = this.#filteredMessages.length;
97
+
39
98
  // Calculate visible range with scrolling
40
99
  const startIndex = Math.max(
41
100
  0,
42
- Math.min(this.#selectedIndex - Math.floor(this.#maxVisible / 2), this.messages.length - this.#maxVisible),
101
+ Math.min(this.#selectedIndex - Math.floor(this.#maxVisible / 2), total - this.#maxVisible),
43
102
  );
44
- const endIndex = Math.min(startIndex + this.#maxVisible, this.messages.length);
103
+ const endIndex = Math.min(startIndex + this.#maxVisible, total);
45
104
 
46
105
  // Render visible messages (2 lines per message + blank line)
47
106
  for (let i = startIndex; i < endIndex; i++) {
48
- const message = this.messages[i];
107
+ const message = this.#filteredMessages[i];
108
+ if (!message) continue;
49
109
  const isSelected = i === this.#selectedIndex;
50
110
 
51
111
  // Normalize message to single line
@@ -60,44 +120,59 @@ class UserMessageList implements Component {
60
120
  lines.push(messageLine);
61
121
 
62
122
  // Second line: metadata (position in history)
63
- const position = i + 1;
123
+ const position = this.messages.indexOf(message) + 1;
64
124
  const metadata = ` Message ${position} of ${this.messages.length}`;
65
125
  const metadataLine = theme.fg("muted", metadata);
66
126
  lines.push(metadataLine);
67
127
  lines.push(""); // Blank line between messages
68
128
  }
69
129
 
70
- // Add scroll indicator if needed
71
- if (startIndex > 0 || endIndex < this.messages.length) {
72
- const scrollInfo = theme.fg("muted", ` (${this.#selectedIndex + 1}/${this.messages.length})`);
73
- lines.push(scrollInfo);
130
+ if (total === 0) {
131
+ lines.push(theme.fg("muted", " No matching messages"));
132
+ }
133
+
134
+ // Add scroll/search indicator if needed
135
+ if (startIndex > 0 || endIndex < total || this.#shouldRenderSearchStatus()) {
136
+ lines.push(this.#renderStatusLine(total));
74
137
  }
75
138
 
76
139
  return lines;
77
140
  }
78
141
 
79
142
  handleInput(keyData: string): void {
143
+ // Escape / cancel
144
+ if (matchesSelectCancel(keyData)) {
145
+ if (this.onCancel) {
146
+ this.onCancel();
147
+ }
148
+ return;
149
+ }
150
+
151
+ if (this.#handleSearchInput(keyData)) {
152
+ return;
153
+ }
154
+
80
155
  // Up arrow - go to previous (older) message, wrap to bottom when at top
81
156
  if (matchesSelectUp(keyData)) {
82
- this.#selectedIndex = this.#selectedIndex === 0 ? this.messages.length - 1 : this.#selectedIndex - 1;
157
+ if (this.#filteredMessages.length > 0) {
158
+ this.#selectedIndex =
159
+ this.#selectedIndex === 0 ? this.#filteredMessages.length - 1 : this.#selectedIndex - 1;
160
+ }
83
161
  }
84
162
  // Down arrow - go to next (newer) message, wrap to top when at bottom
85
163
  else if (matchesSelectDown(keyData)) {
86
- this.#selectedIndex = this.#selectedIndex === this.messages.length - 1 ? 0 : this.#selectedIndex + 1;
164
+ if (this.#filteredMessages.length > 0) {
165
+ this.#selectedIndex =
166
+ this.#selectedIndex === this.#filteredMessages.length - 1 ? 0 : this.#selectedIndex + 1;
167
+ }
87
168
  }
88
169
  // Enter - select message and branch
89
170
  else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
90
- const selected = this.messages[this.#selectedIndex];
171
+ const selected = this.#filteredMessages[this.#selectedIndex];
91
172
  if (selected && this.onSelect) {
92
173
  this.onSelect(selected.id);
93
174
  }
94
175
  }
95
- // Escape / cancel
96
- else if (matchesSelectCancel(keyData)) {
97
- if (this.onCancel) {
98
- this.onCancel();
99
- }
100
- }
101
176
  }
102
177
  }
103
178
 
@@ -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,62 @@ export class CommandController {
1122
1123
  return this.executeCompaction(customInstructions, false);
1123
1124
  }
1124
1125
 
1126
+ /**
1127
+ * TUI handler for `/shake`. `elide`/`images` are instant structural drops;
1128
+ * `summary` runs the local on-device compressor behind a cancelable loader
1129
+ * (Esc aborts via `abortCompaction`). Rebuilds the chat and reports counts.
1130
+ */
1131
+ async handleShakeCommand(mode: ShakeMode): Promise<void> {
1132
+ let result: ShakeResult;
1133
+ if (mode === "summary") {
1134
+ if (this.ctx.loadingAnimation) {
1135
+ this.ctx.loadingAnimation.stop();
1136
+ this.ctx.loadingAnimation = undefined;
1137
+ }
1138
+ this.ctx.statusContainer.clear();
1139
+ const originalOnEscape = this.ctx.editor.onEscape;
1140
+ this.ctx.editor.onEscape = () => {
1141
+ this.ctx.session.abortCompaction();
1142
+ };
1143
+ const loader = new Loader(
1144
+ this.ctx.ui,
1145
+ spinner => theme.fg("accent", spinner),
1146
+ text => theme.fg("muted", text),
1147
+ "Shaking context (summary)… (esc to cancel)",
1148
+ getSymbolTheme().spinnerFrames,
1149
+ );
1150
+ this.ctx.statusContainer.addChild(loader);
1151
+ this.ctx.ui.requestRender();
1152
+ try {
1153
+ result = await this.ctx.session.shake("summary");
1154
+ } catch (error) {
1155
+ this.ctx.showError(`Shake failed: ${error instanceof Error ? error.message : String(error)}`);
1156
+ return;
1157
+ } finally {
1158
+ loader.stop();
1159
+ this.ctx.statusContainer.clear();
1160
+ this.ctx.editor.onEscape = originalOnEscape;
1161
+ }
1162
+ } else {
1163
+ try {
1164
+ result = await this.ctx.session.shake(mode);
1165
+ } catch (error) {
1166
+ this.ctx.showError(`Shake failed: ${error instanceof Error ? error.message : String(error)}`);
1167
+ return;
1168
+ }
1169
+ }
1170
+
1171
+ const dropped = result.toolResultsDropped + result.blocksDropped + (result.imagesDropped ?? 0);
1172
+ if (dropped === 0) {
1173
+ this.ctx.showStatus("Nothing to shake.");
1174
+ return;
1175
+ }
1176
+ this.ctx.rebuildChatFromMessages();
1177
+ this.ctx.statusLine.invalidate();
1178
+ this.ctx.updateEditorTopBorder();
1179
+ this.ctx.showStatus(formatShakeSummary(result));
1180
+ }
1181
+
1125
1182
  async handleSkillCommand(skillPath: string, args: string): Promise<void> {
1126
1183
  try {
1127
1184
  const content = await Bun.file(skillPath).text();
@@ -25,6 +25,17 @@ type AgentSessionEventKind = AgentSessionEvent["type"];
25
25
 
26
26
  const IRC_MESSAGE_VISIBLE_TTL_MS = 10_000;
27
27
 
28
+ // Events that change which foreground tools are executing, or that reset a turn.
29
+ // The eager native-scrollback rebuild mode is recomputed only on these — other
30
+ // events (assistant text streaming, IRC, notices) leave it untouched so plain
31
+ // streaming keeps the no-yank deferral.
32
+ const TOOL_RENDER_MODE_EVENTS: Record<string, true> = {
33
+ agent_start: true,
34
+ tool_execution_start: true,
35
+ tool_execution_update: true,
36
+ tool_execution_end: true,
37
+ };
38
+
28
39
  type AgentSessionEventHandlers = {
29
40
  [E in AgentSessionEventKind]: (event: Extract<AgentSessionEvent, { type: E }>) => Promise<void>;
30
41
  };
@@ -65,7 +76,11 @@ export class EventController {
65
76
  todo_auto_clear: e => this.#handleTodoAutoClear(e),
66
77
  irc_message: e => this.#handleIrcMessage(e),
67
78
  notice: e => this.#handleNotice(e),
68
- thinking_level_changed: async () => {},
79
+ thinking_level_changed: async () => {
80
+ this.ctx.statusLine.invalidate();
81
+ this.ctx.updateEditorBorderColor();
82
+ this.ctx.ui.requestRender();
83
+ },
69
84
  goal_updated: async () => {},
70
85
  } satisfies AgentSessionEventHandlers;
71
86
  }
@@ -154,6 +169,27 @@ export class EventController {
154
169
 
155
170
  const run = this.#handlers[event.type] as (e: AgentSessionEvent) => Promise<void>;
156
171
  await run(event);
172
+ // While a foreground tool is executing, its streaming result re-renders and can
173
+ // re-lay-out rows that already scrolled into native scrollback. Let the TUI
174
+ // rebuild history on those offscreen edits (a snap to the tail is acceptable
175
+ // mid-tool) instead of deferring, which would leave stale/duplicated rows.
176
+ // Background-running tools are excluded so their late async updates — and the
177
+ // assistant text that streams alongside them — keep the no-yank deferral;
178
+ // agent_start resets the mode at every turn boundary.
179
+ if (TOOL_RENDER_MODE_EVENTS[event.type]) {
180
+ this.#refreshToolRenderMode();
181
+ }
182
+ }
183
+
184
+ #refreshToolRenderMode(): void {
185
+ let foregroundToolActive = false;
186
+ for (const toolCallId of this.ctx.pendingTools.keys()) {
187
+ if (!this.#backgroundToolCallIds.has(toolCallId)) {
188
+ foregroundToolActive = true;
189
+ break;
190
+ }
191
+ }
192
+ this.ctx.ui.setEagerNativeScrollbackRebuild(foregroundToolActive);
157
193
  }
158
194
 
159
195
  async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
@@ -606,7 +642,14 @@ export class EventController {
606
642
  : event.reason === "idle"
607
643
  ? "Idle "
608
644
  : "";
609
- const actionLabel = event.action === "handoff" ? "Auto-handoff" : "Auto context-full maintenance";
645
+ const actionLabel =
646
+ event.action === "handoff"
647
+ ? "Auto-handoff"
648
+ : event.action === "shake"
649
+ ? "Auto-shake"
650
+ : event.action === "shake-summary"
651
+ ? "Auto-shake (summary)"
652
+ : "Auto context-full maintenance";
610
653
  this.ctx.autoCompactionLoader = new Loader(
611
654
  this.ctx.ui,
612
655
  spinner => theme.fg("accent", spinner),
@@ -630,8 +673,27 @@ export class EventController {
630
673
  this.ctx.statusContainer.clear();
631
674
  }
632
675
  const isHandoffAction = event.action === "handoff";
676
+ const isShakeAction = event.action === "shake" || event.action === "shake-summary";
633
677
  if (event.aborted) {
634
- this.ctx.showStatus(isHandoffAction ? "Auto-handoff cancelled" : "Auto context-full maintenance cancelled");
678
+ this.ctx.showStatus(
679
+ isHandoffAction
680
+ ? "Auto-handoff cancelled"
681
+ : isShakeAction
682
+ ? "Auto-shake cancelled"
683
+ : "Auto context-full maintenance cancelled",
684
+ );
685
+ } else if (isShakeAction) {
686
+ // Shake produces no CompactionResult; rebuild on success, suppress benign skips.
687
+ if (event.errorMessage) {
688
+ this.ctx.showWarning(event.errorMessage);
689
+ } else if (!event.skipped) {
690
+ this.ctx.rebuildChatFromMessages();
691
+ this.ctx.statusLine.invalidate();
692
+ this.ctx.updateEditorTopBorder();
693
+ this.ctx.showStatus(
694
+ event.action === "shake-summary" ? "Auto-shake (summary) completed" : "Auto-shake completed",
695
+ );
696
+ }
635
697
  } else if (event.result) {
636
698
  this.ctx.rebuildChatFromMessages();
637
699
  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 {