@oh-my-pi/pi-coding-agent 4.1.0 → 4.2.1

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 (90) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +2 -1
  3. package/docs/sdk.md +0 -3
  4. package/package.json +6 -5
  5. package/src/config.ts +9 -0
  6. package/src/core/agent-session.ts +3 -3
  7. package/src/core/agent-storage.ts +450 -0
  8. package/src/core/auth-storage.ts +102 -183
  9. package/src/core/compaction/branch-summarization.ts +5 -4
  10. package/src/core/compaction/compaction.ts +7 -6
  11. package/src/core/compaction/utils.ts +6 -11
  12. package/src/core/custom-commands/bundled/review/index.ts +22 -94
  13. package/src/core/custom-share.ts +66 -0
  14. package/src/core/export-html/index.ts +1 -33
  15. package/src/core/history-storage.ts +15 -7
  16. package/src/core/prompt-templates.ts +271 -1
  17. package/src/core/sdk.ts +14 -3
  18. package/src/core/settings-manager.ts +100 -34
  19. package/src/core/slash-commands.ts +4 -1
  20. package/src/core/storage-migration.ts +215 -0
  21. package/src/core/system-prompt.ts +130 -290
  22. package/src/core/title-generator.ts +3 -2
  23. package/src/core/tools/ask.ts +2 -2
  24. package/src/core/tools/bash.ts +2 -1
  25. package/src/core/tools/calculator.ts +2 -1
  26. package/src/core/tools/complete.ts +5 -2
  27. package/src/core/tools/edit.ts +2 -1
  28. package/src/core/tools/find.ts +2 -1
  29. package/src/core/tools/gemini-image.ts +2 -1
  30. package/src/core/tools/git.ts +2 -2
  31. package/src/core/tools/grep.ts +2 -1
  32. package/src/core/tools/index.test.ts +0 -28
  33. package/src/core/tools/index.ts +0 -6
  34. package/src/core/tools/lsp/index.ts +2 -1
  35. package/src/core/tools/output.ts +2 -1
  36. package/src/core/tools/read.ts +4 -1
  37. package/src/core/tools/ssh.ts +4 -2
  38. package/src/core/tools/task/agents.ts +56 -30
  39. package/src/core/tools/task/commands.ts +5 -8
  40. package/src/core/tools/task/index.ts +7 -15
  41. package/src/core/tools/web-fetch.ts +2 -1
  42. package/src/core/tools/web-search/auth.ts +106 -16
  43. package/src/core/tools/web-search/index.ts +3 -2
  44. package/src/core/tools/web-search/providers/anthropic.ts +44 -6
  45. package/src/core/tools/write.ts +2 -1
  46. package/src/core/voice.ts +3 -1
  47. package/src/discovery/builtin.ts +9 -54
  48. package/src/discovery/claude.ts +16 -69
  49. package/src/discovery/codex.ts +11 -36
  50. package/src/discovery/helpers.ts +52 -1
  51. package/src/main.ts +1 -1
  52. package/src/migrations.ts +20 -20
  53. package/src/modes/interactive/controllers/command-controller.ts +527 -0
  54. package/src/modes/interactive/controllers/event-controller.ts +340 -0
  55. package/src/modes/interactive/controllers/extension-ui-controller.ts +600 -0
  56. package/src/modes/interactive/controllers/input-controller.ts +585 -0
  57. package/src/modes/interactive/controllers/selector-controller.ts +585 -0
  58. package/src/modes/interactive/interactive-mode.ts +363 -3139
  59. package/src/modes/interactive/theme/theme.ts +5 -5
  60. package/src/modes/interactive/types.ts +189 -0
  61. package/src/modes/interactive/utils/ui-helpers.ts +449 -0
  62. package/src/modes/interactive/utils/voice-manager.ts +96 -0
  63. package/src/prompts/{explore.md → agents/explore.md} +7 -5
  64. package/src/prompts/agents/frontmatter.md +7 -0
  65. package/src/prompts/{plan.md → agents/plan.md} +3 -3
  66. package/src/prompts/agents/planner.md +112 -0
  67. package/src/prompts/agents/task.md +15 -0
  68. package/src/prompts/review-request.md +44 -8
  69. package/src/prompts/system/custom-system-prompt.md +80 -0
  70. package/src/prompts/system/file-operations.md +12 -0
  71. package/src/prompts/system/system-prompt.md +237 -0
  72. package/src/prompts/system/title-system.md +2 -0
  73. package/src/prompts/tools/bash.md +1 -1
  74. package/src/prompts/tools/read.md +1 -1
  75. package/src/prompts/tools/task.md +34 -22
  76. package/src/core/tools/rulebook.ts +0 -132
  77. package/src/prompts/architect-plan.md +0 -10
  78. package/src/prompts/implement-with-critic.md +0 -11
  79. package/src/prompts/implement.md +0 -11
  80. package/src/prompts/system-prompt.md +0 -43
  81. package/src/prompts/task.md +0 -14
  82. package/src/prompts/title-system.md +0 -8
  83. /package/src/prompts/{init.md → agents/init.md} +0 -0
  84. /package/src/prompts/{reviewer.md → agents/reviewer.md} +0 -0
  85. /package/src/prompts/{branch-summary-preamble.md → compaction/branch-summary-preamble.md} +0 -0
  86. /package/src/prompts/{branch-summary.md → compaction/branch-summary.md} +0 -0
  87. /package/src/prompts/{compaction-summary.md → compaction/compaction-summary.md} +0 -0
  88. /package/src/prompts/{compaction-turn-prefix.md → compaction/compaction-turn-prefix.md} +0 -0
  89. /package/src/prompts/{compaction-update-summary.md → compaction/compaction-update-summary.md} +0 -0
  90. /package/src/prompts/{summarization-system.md → system/summarization-system.md} +0 -0
@@ -0,0 +1,585 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
5
+ import { nanoid } from "nanoid";
6
+ import type { AgentSessionEvent } from "../../../core/agent-session";
7
+ import { generateSessionTitle, setTerminalTitle } from "../../../core/title-generator";
8
+ import { readImageFromClipboard } from "../../../utils/clipboard";
9
+ import { resizeImage } from "../../../utils/image-resize";
10
+ import { theme } from "../theme/theme";
11
+ import type { InteractiveModeContext } from "../types";
12
+
13
+ interface Expandable {
14
+ setExpanded(expanded: boolean): void;
15
+ }
16
+
17
+ function isExpandable(obj: unknown): obj is Expandable {
18
+ return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
19
+ }
20
+
21
+ export class InputController {
22
+ constructor(private ctx: InteractiveModeContext) {}
23
+
24
+ setupKeyHandlers(): void {
25
+ this.ctx.editor.onEscape = () => {
26
+ if (this.ctx.loadingAnimation) {
27
+ // Abort and restore queued messages to editor
28
+ const queuedMessages = this.ctx.session.clearQueue();
29
+ const queuedText = [...queuedMessages.steering, ...queuedMessages.followUp].join("\n\n");
30
+ const currentText = this.ctx.editor.getText();
31
+ const combinedText = [queuedText, currentText].filter((t) => t.trim()).join("\n\n");
32
+ this.ctx.editor.setText(combinedText);
33
+ this.ctx.updatePendingMessagesDisplay();
34
+ this.ctx.agent.abort();
35
+ } else if (this.ctx.session.isBashRunning) {
36
+ this.ctx.session.abortBash();
37
+ } else if (this.ctx.isBashMode) {
38
+ this.ctx.editor.setText("");
39
+ this.ctx.isBashMode = false;
40
+ this.ctx.updateEditorBorderColor();
41
+ } else if (!this.ctx.editor.getText().trim()) {
42
+ // Double-escape with empty editor triggers /tree or /branch based on setting
43
+ const now = Date.now();
44
+ if (now - this.ctx.lastEscapeTime < 500) {
45
+ if (this.ctx.settingsManager.getDoubleEscapeAction() === "tree") {
46
+ this.ctx.showTreeSelector();
47
+ } else {
48
+ this.ctx.showUserMessageSelector();
49
+ }
50
+ this.ctx.lastEscapeTime = 0;
51
+ } else {
52
+ this.ctx.lastEscapeTime = now;
53
+ }
54
+ }
55
+ };
56
+
57
+ this.ctx.editor.onCtrlC = () => this.handleCtrlC();
58
+ this.ctx.editor.onCtrlD = () => this.handleCtrlD();
59
+ this.ctx.editor.onCtrlZ = () => this.handleCtrlZ();
60
+ this.ctx.editor.onShiftTab = () => this.cycleThinkingLevel();
61
+ this.ctx.editor.onCtrlP = () => this.cycleRoleModel();
62
+ this.ctx.editor.onShiftCtrlP = () => this.cycleRoleModel({ temporary: true });
63
+ this.ctx.editor.onCtrlY = () => this.ctx.showModelSelector({ temporaryOnly: true });
64
+
65
+ // Global debug handler on TUI (works regardless of focus)
66
+ this.ctx.ui.onDebug = () => this.ctx.handleDebugCommand();
67
+ this.ctx.editor.onCtrlL = () => this.ctx.showModelSelector();
68
+ this.ctx.editor.onCtrlR = () => this.ctx.showHistorySearch();
69
+ this.ctx.editor.onCtrlO = () => this.toggleToolOutputExpansion();
70
+ this.ctx.editor.onCtrlT = () => this.toggleThinkingBlockVisibility();
71
+ this.ctx.editor.onCtrlG = () => this.openExternalEditor();
72
+ this.ctx.editor.onQuestionMark = () => this.ctx.handleHotkeysCommand();
73
+ this.ctx.editor.onCtrlV = () => this.handleImagePaste();
74
+ this.ctx.editor.onAltUp = () => this.handleDequeue();
75
+
76
+ // Wire up extension shortcuts
77
+ this.registerExtensionShortcuts();
78
+
79
+ this.ctx.editor.onChange = (text: string) => {
80
+ const wasBashMode = this.ctx.isBashMode;
81
+ this.ctx.isBashMode = text.trimStart().startsWith("!");
82
+ if (wasBashMode !== this.ctx.isBashMode) {
83
+ this.ctx.updateEditorBorderColor();
84
+ }
85
+ };
86
+
87
+ this.ctx.editor.onAltEnter = async (text: string) => {
88
+ text = text.trim();
89
+ if (!text) return;
90
+
91
+ // Queue follow-up messages while compaction is running
92
+ if (this.ctx.session.isCompacting) {
93
+ this.ctx.queueCompactionMessage(text, "followUp");
94
+ return;
95
+ }
96
+
97
+ // Alt+Enter queues a follow-up message (waits until agent finishes)
98
+ // This handles extension commands (execute immediately), prompt template expansion, and queueing
99
+ if (this.ctx.session.isStreaming) {
100
+ this.ctx.editor.addToHistory(text);
101
+ this.ctx.editor.setText("");
102
+ await this.ctx.session.prompt(text, { streamingBehavior: "followUp" });
103
+ this.ctx.updatePendingMessagesDisplay();
104
+ this.ctx.ui.requestRender();
105
+ }
106
+ // If not streaming, Alt+Enter acts like regular Enter (trigger onSubmit)
107
+ else if (this.ctx.editor.onSubmit) {
108
+ this.ctx.editor.onSubmit(text);
109
+ }
110
+ };
111
+ }
112
+
113
+ setupEditorSubmitHandler(): void {
114
+ this.ctx.editor.onSubmit = async (text: string) => {
115
+ text = text.trim();
116
+
117
+ // Empty submit while streaming with queued messages: flush queues immediately
118
+ if (!text && this.ctx.session.isStreaming && this.ctx.session.queuedMessageCount > 0) {
119
+ // Abort current stream and let queued messages be processed
120
+ await this.ctx.session.abort();
121
+ return;
122
+ }
123
+
124
+ if (!text) return;
125
+
126
+ // Handle slash commands
127
+ if (text === "/settings") {
128
+ this.ctx.showSettingsSelector();
129
+ this.ctx.editor.setText("");
130
+ return;
131
+ }
132
+ if (text === "/model") {
133
+ this.ctx.showModelSelector();
134
+ this.ctx.editor.setText("");
135
+ return;
136
+ }
137
+ if (text.startsWith("/export")) {
138
+ await this.ctx.handleExportCommand(text);
139
+ this.ctx.editor.setText("");
140
+ return;
141
+ }
142
+ if (text === "/dump") {
143
+ await this.ctx.handleDumpCommand();
144
+ this.ctx.editor.setText("");
145
+ return;
146
+ }
147
+ if (text === "/share") {
148
+ await this.ctx.handleShareCommand();
149
+ this.ctx.editor.setText("");
150
+ return;
151
+ }
152
+ if (text === "/copy") {
153
+ await this.ctx.handleCopyCommand();
154
+ this.ctx.editor.setText("");
155
+ return;
156
+ }
157
+ if (text === "/session") {
158
+ this.ctx.handleSessionCommand();
159
+ this.ctx.editor.setText("");
160
+ return;
161
+ }
162
+ if (text === "/changelog") {
163
+ this.ctx.handleChangelogCommand();
164
+ this.ctx.editor.setText("");
165
+ return;
166
+ }
167
+ if (text === "/hotkeys") {
168
+ this.ctx.handleHotkeysCommand();
169
+ this.ctx.editor.setText("");
170
+ return;
171
+ }
172
+ if (text === "/extensions" || text === "/status") {
173
+ this.ctx.showExtensionsDashboard();
174
+ this.ctx.editor.setText("");
175
+ return;
176
+ }
177
+ if (text === "/branch") {
178
+ if (this.ctx.settingsManager.getDoubleEscapeAction() === "tree") {
179
+ this.ctx.showTreeSelector();
180
+ } else {
181
+ this.ctx.showUserMessageSelector();
182
+ }
183
+ this.ctx.editor.setText("");
184
+ return;
185
+ }
186
+ if (text === "/tree") {
187
+ this.ctx.showTreeSelector();
188
+ this.ctx.editor.setText("");
189
+ return;
190
+ }
191
+ if (text === "/login") {
192
+ this.ctx.showOAuthSelector("login");
193
+ this.ctx.editor.setText("");
194
+ return;
195
+ }
196
+ if (text === "/logout") {
197
+ this.ctx.showOAuthSelector("logout");
198
+ this.ctx.editor.setText("");
199
+ return;
200
+ }
201
+ if (text === "/new") {
202
+ this.ctx.editor.setText("");
203
+ await this.ctx.handleClearCommand();
204
+ return;
205
+ }
206
+ if (text === "/compact" || text.startsWith("/compact ")) {
207
+ const customInstructions = text.startsWith("/compact ") ? text.slice(9).trim() : undefined;
208
+ this.ctx.editor.setText("");
209
+ await this.ctx.handleCompactCommand(customInstructions);
210
+ return;
211
+ }
212
+ if (text === "/background" || text === "/bg") {
213
+ this.ctx.editor.setText("");
214
+ this.handleBackgroundCommand();
215
+ return;
216
+ }
217
+ if (text === "/debug") {
218
+ this.ctx.handleDebugCommand();
219
+ this.ctx.editor.setText("");
220
+ return;
221
+ }
222
+ if (text === "/arminsayshi") {
223
+ this.ctx.handleArminSaysHi();
224
+ this.ctx.editor.setText("");
225
+ return;
226
+ }
227
+ if (text === "/resume") {
228
+ this.ctx.showSessionSelector();
229
+ this.ctx.editor.setText("");
230
+ return;
231
+ }
232
+ if (text === "/exit") {
233
+ this.ctx.editor.setText("");
234
+ void this.ctx.shutdown();
235
+ return;
236
+ }
237
+
238
+ // Handle bash command (! for normal, !! for excluded from context)
239
+ if (text.startsWith("!")) {
240
+ const isExcluded = text.startsWith("!!");
241
+ const command = isExcluded ? text.slice(2).trim() : text.slice(1).trim();
242
+ if (command) {
243
+ if (this.ctx.session.isBashRunning) {
244
+ this.ctx.showWarning("A bash command is already running. Press Esc to cancel it first.");
245
+ this.ctx.editor.setText(text);
246
+ return;
247
+ }
248
+ this.ctx.editor.addToHistory(text);
249
+ await this.ctx.handleBashCommand(command, isExcluded);
250
+ this.ctx.isBashMode = false;
251
+ this.ctx.updateEditorBorderColor();
252
+ return;
253
+ }
254
+ }
255
+
256
+ // Queue input during compaction
257
+ if (this.ctx.session.isCompacting) {
258
+ if (this.ctx.pendingImages.length > 0) {
259
+ this.ctx.showStatus("Compaction in progress. Retry after it completes to send images.");
260
+ return;
261
+ }
262
+ this.ctx.queueCompactionMessage(text, "steer");
263
+ return;
264
+ }
265
+
266
+ // If streaming, use prompt() with steer behavior
267
+ // This handles extension commands (execute immediately), prompt template expansion, and queueing
268
+ if (this.ctx.session.isStreaming) {
269
+ this.ctx.editor.addToHistory(text);
270
+ this.ctx.editor.setText("");
271
+ const images = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined;
272
+ this.ctx.pendingImages = [];
273
+ await this.ctx.session.prompt(text, { streamingBehavior: "steer", images });
274
+ this.ctx.updatePendingMessagesDisplay();
275
+ this.ctx.ui.requestRender();
276
+ return;
277
+ }
278
+
279
+ // Normal message submission
280
+ // First, move any pending bash components to chat
281
+ this.ctx.flushPendingBashComponents();
282
+
283
+ // Generate session title on first message
284
+ const hasUserMessages = this.ctx.agent.state.messages.some((m: AgentMessage) => m.role === "user");
285
+ if (!hasUserMessages && !this.ctx.sessionManager.getSessionTitle()) {
286
+ const registry = this.ctx.session.modelRegistry;
287
+ const smolModel = this.ctx.settingsManager.getModelRole("smol");
288
+ generateSessionTitle(text, registry, smolModel, this.ctx.session.sessionId)
289
+ .then(async (title) => {
290
+ if (title) {
291
+ await this.ctx.sessionManager.setSessionTitle(title);
292
+ setTerminalTitle(`omp: ${title}`);
293
+ }
294
+ })
295
+ .catch(() => {});
296
+ }
297
+
298
+ if (this.ctx.onInputCallback) {
299
+ // Include any pending images from clipboard paste
300
+ const images = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined;
301
+ this.ctx.pendingImages = [];
302
+ this.ctx.onInputCallback({ text, images });
303
+ }
304
+ this.ctx.editor.addToHistory(text);
305
+ };
306
+ }
307
+
308
+ handleCtrlC(): void {
309
+ const now = Date.now();
310
+ if (now - this.ctx.lastSigintTime < 500) {
311
+ void this.ctx.shutdown();
312
+ } else {
313
+ this.ctx.clearEditor();
314
+ this.ctx.lastSigintTime = now;
315
+ }
316
+ }
317
+
318
+ handleCtrlD(): void {
319
+ // Only called when editor is empty (enforced by CustomEditor)
320
+ void this.ctx.shutdown();
321
+ }
322
+
323
+ handleCtrlZ(): void {
324
+ // Set up handler to restore TUI when resumed
325
+ process.once("SIGCONT", () => {
326
+ this.ctx.ui.start();
327
+ this.ctx.ui.requestRender(true);
328
+ });
329
+
330
+ // Stop the TUI (restore terminal to normal mode)
331
+ this.ctx.ui.stop();
332
+
333
+ // Send SIGTSTP to process group (pid=0 means all processes in group)
334
+ process.kill(0, "SIGTSTP");
335
+ }
336
+
337
+ handleDequeue(): void {
338
+ const message = this.ctx.session.popLastQueuedMessage();
339
+ if (!message) return;
340
+
341
+ // Prepend to existing editor text (if any)
342
+ const currentText = this.ctx.editor.getText();
343
+ const newText = currentText ? `${message}\n\n${currentText}` : message;
344
+ this.ctx.editor.setText(newText);
345
+ this.ctx.updatePendingMessagesDisplay();
346
+ this.ctx.ui.requestRender();
347
+ }
348
+
349
+ handleBackgroundCommand(): void {
350
+ if (this.ctx.isBackgrounded) {
351
+ this.ctx.showStatus("Background mode already enabled");
352
+ return;
353
+ }
354
+ if (!this.ctx.session.isStreaming && this.ctx.session.queuedMessageCount === 0) {
355
+ this.ctx.showWarning("Agent is idle; nothing to background");
356
+ return;
357
+ }
358
+
359
+ this.ctx.isBackgrounded = true;
360
+ const backgroundUiContext = this.ctx.createBackgroundUiContext();
361
+
362
+ // Background mode disables interactive UI so tools like ask fail fast.
363
+ this.ctx.setToolUIContext(backgroundUiContext, false);
364
+ this.ctx.initializeHookRunner(backgroundUiContext, false);
365
+
366
+ if (this.ctx.loadingAnimation) {
367
+ this.ctx.loadingAnimation.stop();
368
+ this.ctx.loadingAnimation = undefined;
369
+ }
370
+ if (this.ctx.autoCompactionLoader) {
371
+ this.ctx.autoCompactionLoader.stop();
372
+ this.ctx.autoCompactionLoader = undefined;
373
+ }
374
+ if (this.ctx.retryLoader) {
375
+ this.ctx.retryLoader.stop();
376
+ this.ctx.retryLoader = undefined;
377
+ }
378
+ this.ctx.statusContainer.clear();
379
+ this.ctx.statusLine.dispose();
380
+
381
+ if (this.ctx.unsubscribe) {
382
+ this.ctx.unsubscribe();
383
+ }
384
+ this.ctx.unsubscribe = this.ctx.session.subscribe(async (event: AgentSessionEvent) => {
385
+ await this.ctx.handleBackgroundEvent(event);
386
+ });
387
+
388
+ // Backgrounding keeps the current process to preserve in-flight agent state.
389
+ if (this.ctx.isInitialized) {
390
+ this.ctx.ui.stop();
391
+ this.ctx.isInitialized = false;
392
+ }
393
+
394
+ process.stdout.write("Background mode enabled. Run `bg` to continue in background.\n");
395
+
396
+ if (process.platform === "win32" || !process.stdout.isTTY) {
397
+ process.stdout.write("Backgrounding requires POSIX job control; continuing in foreground.\n");
398
+ return;
399
+ }
400
+
401
+ process.kill(0, "SIGTSTP");
402
+ }
403
+
404
+ async handleImagePaste(): Promise<boolean> {
405
+ try {
406
+ const image = await readImageFromClipboard();
407
+ if (image) {
408
+ let imageData = image;
409
+ if (this.ctx.settingsManager.getImageAutoResize()) {
410
+ try {
411
+ const resized = await resizeImage({
412
+ type: "image",
413
+ data: image.data,
414
+ mimeType: image.mimeType,
415
+ });
416
+ imageData = { data: resized.data, mimeType: resized.mimeType };
417
+ } catch {
418
+ imageData = image;
419
+ }
420
+ }
421
+
422
+ this.ctx.pendingImages.push({
423
+ type: "image",
424
+ data: imageData.data,
425
+ mimeType: imageData.mimeType,
426
+ });
427
+ // Insert styled placeholder at cursor like Claude does
428
+ const imageNum = this.ctx.pendingImages.length;
429
+ const placeholder = theme.bold(theme.underline(`[Image #${imageNum}]`));
430
+ this.ctx.editor.insertText(`${placeholder} `);
431
+ this.ctx.ui.requestRender();
432
+ return true;
433
+ }
434
+ // No image in clipboard - show hint
435
+ this.ctx.showStatus("No image in clipboard (use terminal paste for text)");
436
+ return false;
437
+ } catch {
438
+ this.ctx.showStatus("Failed to read clipboard");
439
+ return false;
440
+ }
441
+ }
442
+
443
+ cycleThinkingLevel(): void {
444
+ const newLevel = this.ctx.session.cycleThinkingLevel();
445
+ if (newLevel === undefined) {
446
+ this.ctx.showStatus("Current model does not support thinking");
447
+ } else {
448
+ this.ctx.statusLine.invalidate();
449
+ this.ctx.updateEditorBorderColor();
450
+ }
451
+ }
452
+
453
+ async cycleRoleModel(options?: { temporary?: boolean }): Promise<void> {
454
+ try {
455
+ const roleOrder = ["slow", "default", "smol"];
456
+ const result = await this.ctx.session.cycleRoleModels(roleOrder, options);
457
+ if (!result) {
458
+ this.ctx.showStatus("Only one role model available");
459
+ return;
460
+ }
461
+
462
+ this.ctx.statusLine.invalidate();
463
+ this.ctx.updateEditorBorderColor();
464
+ const roleLabel = result.role === "default" ? "default" : result.role;
465
+ const roleLabelStyled = theme.bold(theme.fg("accent", roleLabel));
466
+ const thinkingStr =
467
+ result.model.reasoning && result.thinkingLevel !== "off" ? ` (thinking: ${result.thinkingLevel})` : "";
468
+ const tempLabel = options?.temporary ? " (temporary)" : "";
469
+ const cycleSeparator = theme.fg("dim", " > ");
470
+ const cycleLabel = roleOrder
471
+ .map((role) => {
472
+ if (role === result.role) {
473
+ return theme.bold(theme.fg("accent", role));
474
+ }
475
+ return theme.fg("muted", role);
476
+ })
477
+ .join(cycleSeparator);
478
+ const orderLabel = ` (cycle: ${cycleLabel})`;
479
+ this.ctx.showStatus(
480
+ `Switched to ${roleLabelStyled}: ${result.model.name || result.model.id}${thinkingStr}${tempLabel}${orderLabel}`,
481
+ { dim: false },
482
+ );
483
+ } catch (error) {
484
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
485
+ }
486
+ }
487
+
488
+ toggleToolOutputExpansion(): void {
489
+ this.ctx.toolOutputExpanded = !this.ctx.toolOutputExpanded;
490
+ for (const child of this.ctx.chatContainer.children) {
491
+ if (isExpandable(child)) {
492
+ child.setExpanded(this.ctx.toolOutputExpanded);
493
+ }
494
+ }
495
+ this.ctx.ui.requestRender();
496
+ }
497
+
498
+ toggleThinkingBlockVisibility(): void {
499
+ this.ctx.hideThinkingBlock = !this.ctx.hideThinkingBlock;
500
+ this.ctx.settingsManager.setHideThinkingBlock(this.ctx.hideThinkingBlock);
501
+
502
+ // Rebuild chat from session messages
503
+ this.ctx.chatContainer.clear();
504
+ this.ctx.rebuildChatFromMessages();
505
+
506
+ // If streaming, re-add the streaming component with updated visibility and re-render
507
+ if (this.ctx.streamingComponent && this.ctx.streamingMessage) {
508
+ this.ctx.streamingComponent.setHideThinkingBlock(this.ctx.hideThinkingBlock);
509
+ this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage);
510
+ this.ctx.chatContainer.addChild(this.ctx.streamingComponent);
511
+ }
512
+
513
+ this.ctx.showStatus(`Thinking blocks: ${this.ctx.hideThinkingBlock ? "hidden" : "visible"}`);
514
+ }
515
+
516
+ openExternalEditor(): void {
517
+ // Determine editor (respect $VISUAL, then $EDITOR)
518
+ const editorCmd = process.env.VISUAL || process.env.EDITOR;
519
+ if (!editorCmd) {
520
+ this.ctx.showWarning("No editor configured. Set $VISUAL or $EDITOR environment variable.");
521
+ return;
522
+ }
523
+
524
+ const currentText = this.ctx.editor.getText();
525
+ const tmpFile = path.join(os.tmpdir(), `omp-editor-${nanoid()}.omp.md`);
526
+
527
+ try {
528
+ // Write current content to temp file
529
+ fs.writeFileSync(tmpFile, currentText, "utf-8");
530
+
531
+ // Stop TUI to release terminal
532
+ this.ctx.ui.stop();
533
+
534
+ // Split by space to support editor arguments (e.g., "code --wait")
535
+ const [editor, ...editorArgs] = editorCmd.split(" ");
536
+
537
+ // Spawn editor synchronously with inherited stdio for interactive editing
538
+ const result = Bun.spawnSync([editor, ...editorArgs, tmpFile], {
539
+ stdin: "inherit",
540
+ stdout: "inherit",
541
+ stderr: "inherit",
542
+ });
543
+
544
+ // On successful exit (exitCode 0), replace editor content
545
+ if (result.exitCode === 0) {
546
+ const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, "");
547
+ this.ctx.editor.setText(newContent);
548
+ }
549
+ // On non-zero exit, keep original text (no action needed)
550
+ } finally {
551
+ // Clean up temp file
552
+ try {
553
+ fs.unlinkSync(tmpFile);
554
+ } catch {
555
+ // Ignore cleanup errors
556
+ }
557
+
558
+ // Restart TUI
559
+ this.ctx.ui.start();
560
+ this.ctx.ui.requestRender();
561
+ }
562
+ }
563
+
564
+ registerExtensionShortcuts(): void {
565
+ const runner = this.ctx.session.extensionRunner;
566
+ if (!runner) return;
567
+
568
+ const shortcuts = runner.getShortcuts();
569
+ for (const [keyId, shortcut] of shortcuts) {
570
+ this.ctx.editor.setCustomKeyHandler(keyId, () => {
571
+ const ctx = runner.createCommandContext();
572
+ try {
573
+ shortcut.handler(ctx);
574
+ } catch (err) {
575
+ runner.emitError({
576
+ extensionPath: shortcut.extensionPath,
577
+ event: "shortcut",
578
+ error: err instanceof Error ? err.message : String(err),
579
+ stack: err instanceof Error ? err.stack : undefined,
580
+ });
581
+ }
582
+ });
583
+ }
584
+ }
585
+ }