@oh-my-pi/pi-coding-agent 12.3.0 → 12.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/docs/custom-tools.md +21 -6
  3. package/docs/extensions.md +20 -0
  4. package/package.json +12 -12
  5. package/src/cli/setup-cli.ts +62 -2
  6. package/src/commands/setup.ts +1 -1
  7. package/src/config/keybindings.ts +6 -2
  8. package/src/config/settings-schema.ts +58 -4
  9. package/src/config/settings.ts +23 -9
  10. package/src/debug/index.ts +26 -19
  11. package/src/debug/log-formatting.ts +60 -0
  12. package/src/debug/log-viewer.ts +903 -0
  13. package/src/debug/report-bundle.ts +87 -8
  14. package/src/discovery/helpers.ts +131 -137
  15. package/src/extensibility/custom-tools/types.ts +44 -6
  16. package/src/extensibility/extensions/types.ts +60 -0
  17. package/src/extensibility/hooks/types.ts +60 -0
  18. package/src/extensibility/skills.ts +4 -2
  19. package/src/lsp/render.ts +1 -1
  20. package/src/main.ts +7 -1
  21. package/src/memories/index.ts +11 -7
  22. package/src/modes/components/bash-execution.ts +16 -9
  23. package/src/modes/components/custom-editor.ts +8 -0
  24. package/src/modes/components/python-execution.ts +16 -7
  25. package/src/modes/components/settings-selector.ts +29 -14
  26. package/src/modes/components/tool-execution.ts +2 -1
  27. package/src/modes/controllers/command-controller.ts +3 -1
  28. package/src/modes/controllers/event-controller.ts +7 -0
  29. package/src/modes/controllers/input-controller.ts +23 -2
  30. package/src/modes/controllers/selector-controller.ts +9 -7
  31. package/src/modes/interactive-mode.ts +84 -1
  32. package/src/modes/rpc/rpc-client.ts +7 -0
  33. package/src/modes/rpc/rpc-mode.ts +8 -0
  34. package/src/modes/rpc/rpc-types.ts +2 -0
  35. package/src/modes/theme/theme.ts +163 -7
  36. package/src/modes/types.ts +1 -0
  37. package/src/patch/hashline.ts +2 -1
  38. package/src/patch/shared.ts +44 -13
  39. package/src/prompts/system/plan-mode-approved.md +5 -0
  40. package/src/prompts/system/subagent-system-prompt.md +1 -0
  41. package/src/prompts/system/system-prompt.md +10 -0
  42. package/src/prompts/tools/todo-write.md +3 -1
  43. package/src/sdk.ts +82 -9
  44. package/src/session/agent-session.ts +137 -29
  45. package/src/session/streaming-output.ts +1 -1
  46. package/src/stt/downloader.ts +71 -0
  47. package/src/stt/index.ts +3 -0
  48. package/src/stt/recorder.ts +351 -0
  49. package/src/stt/setup.ts +52 -0
  50. package/src/stt/stt-controller.ts +160 -0
  51. package/src/stt/transcribe.py +70 -0
  52. package/src/stt/transcriber.ts +91 -0
  53. package/src/task/executor.ts +10 -2
  54. package/src/tools/bash-interactive.ts +10 -6
  55. package/src/tools/fetch.ts +1 -1
  56. package/src/tools/output-meta.ts +6 -2
  57. package/src/web/scrapers/types.ts +1 -0
@@ -7,6 +7,7 @@
7
7
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
8
8
  import type { ImageContent, Message, Model, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai";
9
9
  import type { Component, TUI } from "@oh-my-pi/pi-tui";
10
+ import type { Rule } from "../../capability/rule";
10
11
  import type { ModelRegistry } from "../../config/model-registry";
11
12
  import type { ExecOptions, ExecResult } from "../../exec/exec";
12
13
  import type { Theme } from "../../modes/theme/theme";
@@ -21,6 +22,7 @@ import type {
21
22
  SessionManager,
22
23
  } from "../../session/session-manager";
23
24
  import type { BashToolDetails, FindToolDetails, GrepToolDetails, ReadToolDetails } from "../../tools";
25
+ import type { TodoItem } from "../../tools/todo-write";
24
26
 
25
27
  // Re-export for backward compatibility
26
28
  export type { ExecOptions, ExecResult } from "../../exec/exec";
@@ -389,6 +391,52 @@ export interface TurnEndEvent {
389
391
  toolResults: ToolResultMessage[];
390
392
  }
391
393
 
394
+ /** Event data for auto_compaction_start event. */
395
+ export interface AutoCompactionStartEvent {
396
+ type: "auto_compaction_start";
397
+ reason: "threshold" | "overflow";
398
+ }
399
+
400
+ /** Event data for auto_compaction_end event. */
401
+ export interface AutoCompactionEndEvent {
402
+ type: "auto_compaction_end";
403
+ result: CompactionResult | undefined;
404
+ aborted: boolean;
405
+ willRetry: boolean;
406
+ errorMessage?: string;
407
+ }
408
+
409
+ /** Event data for auto_retry_start event. */
410
+ export interface AutoRetryStartEvent {
411
+ type: "auto_retry_start";
412
+ attempt: number;
413
+ maxAttempts: number;
414
+ delayMs: number;
415
+ errorMessage: string;
416
+ }
417
+
418
+ /** Event data for auto_retry_end event. */
419
+ export interface AutoRetryEndEvent {
420
+ type: "auto_retry_end";
421
+ success: boolean;
422
+ attempt: number;
423
+ finalError?: string;
424
+ }
425
+
426
+ /** Event data for ttsr_triggered event. */
427
+ export interface TtsrTriggeredEvent {
428
+ type: "ttsr_triggered";
429
+ rules: Rule[];
430
+ }
431
+
432
+ /** Event data for todo_reminder event. */
433
+ export interface TodoReminderEvent {
434
+ type: "todo_reminder";
435
+ todos: TodoItem[];
436
+ attempt: number;
437
+ maxAttempts: number;
438
+ }
439
+
392
440
  /**
393
441
  * Event data for tool_call event.
394
442
  * Fired before a tool is executed. Hooks can block execution.
@@ -485,6 +533,12 @@ export type HookEvent =
485
533
  | AgentEndEvent
486
534
  | TurnStartEvent
487
535
  | TurnEndEvent
536
+ | AutoCompactionStartEvent
537
+ | AutoCompactionEndEvent
538
+ | AutoRetryStartEvent
539
+ | AutoRetryEndEvent
540
+ | TtsrTriggeredEvent
541
+ | TodoReminderEvent
488
542
  | ToolCallEvent
489
543
  | ToolResultEvent;
490
544
 
@@ -658,6 +712,12 @@ export interface HookAPI {
658
712
  on(event: "agent_end", handler: HookHandler<AgentEndEvent>): void;
659
713
  on(event: "turn_start", handler: HookHandler<TurnStartEvent>): void;
660
714
  on(event: "turn_end", handler: HookHandler<TurnEndEvent>): void;
715
+ on(event: "auto_compaction_start", handler: HookHandler<AutoCompactionStartEvent>): void;
716
+ on(event: "auto_compaction_end", handler: HookHandler<AutoCompactionEndEvent>): void;
717
+ on(event: "auto_retry_start", handler: HookHandler<AutoRetryStartEvent>): void;
718
+ on(event: "auto_retry_end", handler: HookHandler<AutoRetryEndEvent>): void;
719
+ on(event: "ttsr_triggered", handler: HookHandler<TtsrTriggeredEvent>): void;
720
+ on(event: "todo_reminder", handler: HookHandler<TodoReminderEvent>): void;
661
721
  on(event: "tool_call", handler: HookHandler<ToolCallEvent, ToolCallEventResult>): void;
662
722
  on(event: "tool_result", handler: HookHandler<ToolResultEvent, ToolResultEventResult>): void;
663
723
 
@@ -235,6 +235,8 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise<LoadS
235
235
  return { skills: [], warnings: [] };
236
236
  }
237
237
 
238
+ const anyBuiltInSkillSourceEnabled =
239
+ enableCodexUser || enableClaudeUser || enableClaudeProject || enablePiUser || enablePiProject;
238
240
  // Helper to check if a source is enabled
239
241
  function isSourceEnabled(source: SourceMeta): boolean {
240
242
  const { provider, level } = source;
@@ -243,8 +245,8 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise<LoadS
243
245
  if (provider === "claude" && level === "project") return enableClaudeProject;
244
246
  if (provider === "native" && level === "user") return enablePiUser;
245
247
  if (provider === "native" && level === "project") return enablePiProject;
246
- // For other providers (gemini, cursor, etc.) or custom, default to enabled
247
- return true;
248
+ // For other providers (agents, claude-plugins, etc.), treat them as built-in skill sources.
249
+ return anyBuiltInSkillSourceEnabled;
248
250
  }
249
251
 
250
252
  // Use capability API to load all skills
package/src/lsp/render.ts CHANGED
@@ -282,7 +282,7 @@ function renderHover(
282
282
  }
283
283
 
284
284
  /**
285
- * Syntax highlight code using native WASM highlighter.
285
+ * Syntax highlight code using native highlighter.
286
286
  */
287
287
  function highlightCode(codeText: string, language: string, theme: Theme): string[] {
288
288
  const validLang = language && supportsLanguage(language) ? language : undefined;
package/src/main.ts CHANGED
@@ -560,7 +560,13 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
560
560
  });
561
561
  }
562
562
 
563
- await initTheme(settings.get("theme"), isInteractive, settings.get("symbolPreset"), settings.get("colorBlindMode"));
563
+ await initTheme(
564
+ isInteractive,
565
+ settings.get("symbolPreset"),
566
+ settings.get("colorBlindMode"),
567
+ settings.get("theme.dark"),
568
+ settings.get("theme.light"),
569
+ );
564
570
  debugStartup("main:initTheme2");
565
571
  time("initTheme");
566
572
 
@@ -152,7 +152,7 @@ export async function buildMemoryToolDeveloperInstructions(
152
152
  ): Promise<string | undefined> {
153
153
  const cfg = loadMemoryConfig(settings);
154
154
  if (!cfg.enabled) return undefined;
155
- const memoryRoot = getMemoryRoot(agentDir);
155
+ const memoryRoot = getMemoryRoot(agentDir, settings.getCwd());
156
156
  const summaryPath = path.join(memoryRoot, "memory_summary.md");
157
157
 
158
158
  let text: string;
@@ -176,14 +176,14 @@ export async function buildMemoryToolDeveloperInstructions(
176
176
  /**
177
177
  * Clear all persisted memory state and generated artifacts.
178
178
  */
179
- export async function clearMemoryData(agentDir: string): Promise<void> {
179
+ export async function clearMemoryData(agentDir: string, cwd: string): Promise<void> {
180
180
  const db = openMemoryDb(getAgentDbPath(agentDir));
181
181
  try {
182
182
  clearMemoryDataInDb(db);
183
183
  } finally {
184
184
  closeMemoryDb(db);
185
185
  }
186
- await fs.rm(getMemoryRoot(agentDir), { recursive: true, force: true });
186
+ await fs.rm(getMemoryRoot(agentDir, cwd), { recursive: true, force: true });
187
187
  }
188
188
 
189
189
  /**
@@ -221,7 +221,7 @@ async function runPhase1(options: {
221
221
  const db = openMemoryDb(getAgentDbPath(agentDir));
222
222
  const nowSec = unixNow();
223
223
  const workerId = `memory-${process.pid}`;
224
- const memoryRoot = getMemoryRoot(agentDir);
224
+ const memoryRoot = getMemoryRoot(agentDir, session.sessionManager.getCwd());
225
225
  const currentThreadId = session.sessionManager.getSessionId();
226
226
 
227
227
  try {
@@ -345,7 +345,7 @@ async function runPhase2(options: {
345
345
  const db = openMemoryDb(getAgentDbPath(agentDir));
346
346
  const nowSec = unixNow();
347
347
  const workerId = `memory-${process.pid}`;
348
- const memoryRoot = getMemoryRoot(agentDir);
348
+ const memoryRoot = getMemoryRoot(agentDir, session.sessionManager.getCwd());
349
349
 
350
350
  try {
351
351
  const claimResult = tryClaimGlobalPhase2Job(db, {
@@ -1077,8 +1077,12 @@ function loadMemoryConfig(settings: Settings): MemoryRuntimeConfig {
1077
1077
  };
1078
1078
  }
1079
1079
 
1080
- function getMemoryRoot(agentDir: string): string {
1081
- return path.join(agentDir, "memories");
1080
+ function getMemoryRoot(agentDir: string, cwd: string): string {
1081
+ return path.join(agentDir, "memories", encodeProjectPath(cwd));
1082
+ }
1083
+
1084
+ function encodeProjectPath(cwd: string): string {
1085
+ return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
1082
1086
  }
1083
1087
 
1084
1088
  function unixNow(): number {
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Component for displaying bash command execution with streaming output.
3
3
  */
4
+
5
+ import { sanitizeText } from "@oh-my-pi/pi-natives";
4
6
  import { Container, Loader, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
5
7
  import { getSymbolTheme, theme } from "../../modes/theme/theme";
6
8
  import type { TruncationMeta } from "../../tools/output-meta";
@@ -10,6 +12,7 @@ import { truncateToVisualLines } from "./visual-truncate";
10
12
 
11
13
  // Preview line limit when not expanded (matches tool execution behavior)
12
14
  const PREVIEW_LINES = 20;
15
+ const MAX_DISPLAY_LINE_CHARS = 4000;
13
16
 
14
17
  export class BashExecutionComponent extends Container {
15
18
  #outputLines: string[] = [];
@@ -73,13 +76,15 @@ export class BashExecutionComponent extends Container {
73
76
  }
74
77
 
75
78
  appendOutput(chunk: string): void {
76
- const clean = this.#normalizeOutput(chunk);
79
+ const clean = sanitizeText(chunk);
77
80
 
78
81
  // Append to output lines
79
- const newLines = clean.split("\n");
82
+ const newLines = clean.split("\n").map(line => this.#clampDisplayLine(line));
80
83
  if (this.#outputLines.length > 0 && newLines.length > 0) {
81
84
  // Append first chunk to last line (incomplete line continuation)
82
- this.#outputLines[this.#outputLines.length - 1] += newLines[0];
85
+ this.#outputLines[this.#outputLines.length - 1] = this.#clampDisplayLine(
86
+ `${this.#outputLines[this.#outputLines.length - 1]}${newLines[0]}`,
87
+ );
83
88
  this.#outputLines.push(...newLines.slice(1));
84
89
  } else {
85
90
  this.#outputLines.push(...newLines);
@@ -184,15 +189,17 @@ export class BashExecutionComponent extends Container {
184
189
  }
185
190
  }
186
191
 
187
- #normalizeOutput(text: string): string {
188
- // Strip ANSI codes and normalize line endings
189
- // Note: binary data is already sanitized in tui-renderer.ts executeBashCommand
190
- return Bun.stripANSI(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
192
+ #clampDisplayLine(line: string): string {
193
+ if (line.length <= MAX_DISPLAY_LINE_CHARS) {
194
+ return line;
195
+ }
196
+ const omitted = line.length - MAX_DISPLAY_LINE_CHARS;
197
+ return `${line.slice(0, MAX_DISPLAY_LINE_CHARS)}… [${omitted} chars omitted]`;
191
198
  }
192
199
 
193
200
  #setOutput(output: string): void {
194
- const clean = this.#normalizeOutput(output);
195
- this.#outputLines = clean ? clean.split("\n") : [];
201
+ const clean = sanitizeText(output);
202
+ this.#outputLines = clean ? clean.split("\n").map(line => this.#clampDisplayLine(line)) : [];
196
203
  }
197
204
 
198
205
  /**
@@ -19,6 +19,8 @@ export class CustomEditor extends Editor {
19
19
  onQuestionMark?: () => void;
20
20
  onCapsLock?: () => void;
21
21
  onAltP?: () => void;
22
+ /** Called when Alt+Shift+C is pressed to copy prompt to clipboard. */
23
+ onCopyPrompt?: () => void;
22
24
  /** Called when Ctrl+V is pressed. Returns true if handled (image found), false to fall through to text paste. */
23
25
  onCtrlV?: () => Promise<boolean>;
24
26
  /** Called when Alt+Up is pressed (dequeue keybinding). */
@@ -150,6 +152,12 @@ export class CustomEditor extends Editor {
150
152
  return;
151
153
  }
152
154
 
155
+ // Intercept Alt+Shift+C to copy prompt to clipboard
156
+ if (matchesKey(data, "alt+shift+c") && this.onCopyPrompt) {
157
+ this.onCopyPrompt();
158
+ return;
159
+ }
160
+
153
161
  // Intercept ? when editor is empty to show hotkeys
154
162
  if (data === "?" && this.getText().length === 0 && this.onQuestionMark) {
155
163
  this.onQuestionMark();
@@ -2,6 +2,8 @@
2
2
  * Component for displaying user-initiated Python execution with streaming output.
3
3
  * Shares the same kernel session as the agent's Python tool.
4
4
  */
5
+
6
+ import { sanitizeText } from "@oh-my-pi/pi-natives";
5
7
  import { Container, Loader, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
6
8
  import { getSymbolTheme, highlightCode, theme } from "../../modes/theme/theme";
7
9
  import type { TruncationMeta } from "../../tools/output-meta";
@@ -10,6 +12,7 @@ import { DynamicBorder } from "./dynamic-border";
10
12
  import { truncateToVisualLines } from "./visual-truncate";
11
13
 
12
14
  const PREVIEW_LINES = 20;
15
+ const MAX_DISPLAY_LINE_CHARS = 4000;
13
16
 
14
17
  export class PythonExecutionComponent extends Container {
15
18
  #outputLines: string[] = [];
@@ -70,11 +73,13 @@ export class PythonExecutionComponent extends Container {
70
73
  }
71
74
 
72
75
  appendOutput(chunk: string): void {
73
- const clean = this.#normalizeOutput(chunk);
76
+ const clean = sanitizeText(chunk);
74
77
 
75
- const newLines = clean.split("\n");
78
+ const newLines = clean.split("\n").map(line => this.#clampDisplayLine(line));
76
79
  if (this.#outputLines.length > 0 && newLines.length > 0) {
77
- this.#outputLines[this.#outputLines.length - 1] += newLines[0];
80
+ this.#outputLines[this.#outputLines.length - 1] = this.#clampDisplayLine(
81
+ `${this.#outputLines[this.#outputLines.length - 1]}${newLines[0]}`,
82
+ );
78
83
  this.#outputLines.push(...newLines.slice(1));
79
84
  } else {
80
85
  this.#outputLines.push(...newLines);
@@ -168,13 +173,17 @@ export class PythonExecutionComponent extends Container {
168
173
  }
169
174
  }
170
175
 
171
- #normalizeOutput(text: string): string {
172
- return Bun.stripANSI(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
176
+ #clampDisplayLine(line: string): string {
177
+ if (line.length <= MAX_DISPLAY_LINE_CHARS) {
178
+ return line;
179
+ }
180
+ const omitted = line.length - MAX_DISPLAY_LINE_CHARS;
181
+ return `${line.slice(0, MAX_DISPLAY_LINE_CHARS)}… [${omitted} chars omitted]`;
173
182
  }
174
183
 
175
184
  #setOutput(output: string): void {
176
- const clean = this.#normalizeOutput(output);
177
- this.#outputLines = clean ? clean.split("\n") : [];
185
+ const clean = sanitizeText(output);
186
+ this.#outputLines = clean ? clean.split("\n").map(line => this.#clampDisplayLine(line)) : [];
178
187
  }
179
188
 
180
189
  getOutput(): string {
@@ -20,7 +20,7 @@ import type {
20
20
  StatusLineSeparatorStyle,
21
21
  } from "../../config/settings-schema";
22
22
  import { SETTING_TABS, TAB_METADATA } from "../../config/settings-schema";
23
- import { getSelectListTheme, getSettingsListTheme, theme } from "../../modes/theme/theme";
23
+ import { getCurrentThemeName, getSelectListTheme, getSettingsListTheme, theme } from "../../modes/theme/theme";
24
24
  import { DynamicBorder } from "./dynamic-border";
25
25
  import { PluginSettingsComponent } from "./plugin-settings";
26
26
  import { getSettingsForTab, type SettingDef } from "./settings-defs";
@@ -41,6 +41,7 @@ function getTabBarTheme(): TabBarTheme {
41
41
  class SelectSubmenu extends Container {
42
42
  #selectList: SelectList;
43
43
  #previewText: Text | null = null;
44
+ #previewUpdateRequestId: number = 0;
44
45
 
45
46
  constructor(
46
47
  title: string,
@@ -49,7 +50,7 @@ class SelectSubmenu extends Container {
49
50
  currentValue: string,
50
51
  onSelect: (value: string) => void,
51
52
  onCancel: () => void,
52
- onSelectionChange?: (value: string) => void,
53
+ onSelectionChange?: (value: string) => void | Promise<void>,
53
54
  private readonly getPreview?: () => string,
54
55
  ) {
55
56
  super();
@@ -91,9 +92,19 @@ class SelectSubmenu extends Container {
91
92
 
92
93
  if (onSelectionChange) {
93
94
  this.#selectList.onSelectionChange = item => {
94
- onSelectionChange(item.value);
95
- // Update preview after the preview callback has applied changes
96
- this.#updatePreview();
95
+ const requestId = ++this.#previewUpdateRequestId;
96
+ const result = onSelectionChange(item.value);
97
+ if (result && typeof (result as Promise<void>).then === "function") {
98
+ void (result as Promise<void>).finally(() => {
99
+ if (requestId === this.#previewUpdateRequestId) {
100
+ this.#updatePreview();
101
+ }
102
+ });
103
+ return;
104
+ }
105
+ if (requestId === this.#previewUpdateRequestId) {
106
+ this.#updatePreview();
107
+ }
97
108
  };
98
109
  }
99
110
 
@@ -153,7 +164,7 @@ export interface SettingsCallbacks {
153
164
  /** Called when any setting value changes */
154
165
  onChange: (path: SettingPath, newValue: unknown) => void;
155
166
  /** Called for theme preview while browsing */
156
- onThemePreview?: (theme: string) => void;
167
+ onThemePreview?: (theme: string) => void | Promise<void>;
157
168
  /** Called for status line preview while configuring */
158
169
  onStatusLinePreview?: (settings: StatusLinePreviewSettings) => void;
159
170
  /** Get current rendered status line for inline preview */
@@ -299,17 +310,22 @@ export class SettingsSelectorComponent extends Container {
299
310
  const baseOpt = options.find(o => o.value === level);
300
311
  return baseOpt || { value: level, label: level };
301
312
  });
302
- } else if (def.path === "theme") {
313
+ } else if (def.path === "theme.dark" || def.path === "theme.light") {
303
314
  options = this.context.availableThemes.map(t => ({ value: t, label: t }));
304
315
  }
305
316
 
306
317
  // Preview handlers
307
- let onPreview: ((value: string) => void) | undefined;
318
+ let onPreview: ((value: string) => void | Promise<void>) | undefined;
308
319
  let onPreviewCancel: (() => void) | undefined;
309
320
 
310
- if (def.path === "theme") {
311
- onPreview = this.callbacks.onThemePreview;
312
- onPreviewCancel = () => this.callbacks.onThemePreview?.(currentValue);
321
+ const activeThemeBeforePreview = getCurrentThemeName() ?? currentValue;
322
+ if (def.path === "theme.dark" || def.path === "theme.light") {
323
+ onPreview = value => {
324
+ return this.callbacks.onThemePreview?.(value);
325
+ };
326
+ onPreviewCancel = () => {
327
+ this.callbacks.onThemePreview?.(activeThemeBeforePreview);
328
+ };
313
329
  } else if (def.path === "statusLine.preset") {
314
330
  onPreview = value => {
315
331
  const presetDef = getPreset(
@@ -347,7 +363,8 @@ export class SettingsSelectorComponent extends Container {
347
363
  }
348
364
 
349
365
  // Provide status line preview for theme selection
350
- const getPreview = def.path === "theme" ? this.callbacks.getStatusLinePreview : undefined;
366
+ const isThemeSetting = def.path === "theme.dark" || def.path === "theme.light";
367
+ const getPreview = isThemeSetting ? this.callbacks.getStatusLinePreview : undefined;
351
368
 
352
369
  return new SelectSubmenu(
353
370
  def.label,
@@ -355,9 +372,7 @@ export class SettingsSelectorComponent extends Container {
355
372
  options,
356
373
  currentValue,
357
374
  value => {
358
- // Persist
359
375
  this.#setSettingValue(def.path, value);
360
- // Notify
361
376
  this.callbacks.onChange(def.path, value);
362
377
  done(value);
363
378
  },
@@ -1,4 +1,5 @@
1
1
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
2
+ import { sanitizeText } from "@oh-my-pi/pi-natives";
2
3
  import {
3
4
  Box,
4
5
  type Component,
@@ -12,7 +13,7 @@ import {
12
13
  Text,
13
14
  type TUI,
14
15
  } from "@oh-my-pi/pi-tui";
15
- import { logger, sanitizeText } from "@oh-my-pi/pi-utils";
16
+ import { logger } from "@oh-my-pi/pi-utils";
16
17
  import { getProjectDir } from "@oh-my-pi/pi-utils/dirs";
17
18
  import type { Theme } from "../../modes/theme/theme";
18
19
  import { theme } from "../../modes/theme/theme";
@@ -358,6 +358,7 @@ export class CommandController {
358
358
  handleHotkeysCommand(): void {
359
359
  const expandToolsKey = this.ctx.keybindings.getDisplayString("expandTools") || "Ctrl+O";
360
360
  const planModeKey = this.ctx.keybindings.getDisplayString("togglePlanMode") || "Alt+Shift+P";
361
+ const sttKey = this.ctx.keybindings.getDisplayString("toggleSTT") || "Alt+H";
361
362
  const hotkeys = `
362
363
  **Navigation**
363
364
  | Key | Action |
@@ -394,6 +395,7 @@ export class CommandController {
394
395
  | \`${expandToolsKey}\` | Toggle tool output expansion |
395
396
  | \`Ctrl+T\` | Toggle todo list expansion |
396
397
  | \`Ctrl+G\` | Edit message in external editor |
398
+ | \`${sttKey}\` | Toggle speech-to-text recording |
397
399
  | \`/\` | Slash commands |
398
400
  | \`!\` | Run bash command |
399
401
  | \`!!\` | Run bash command (excluded from context) |
@@ -432,7 +434,7 @@ export class CommandController {
432
434
 
433
435
  if (action === "reset" || action === "clear") {
434
436
  try {
435
- await clearMemoryData(agentDir);
437
+ await clearMemoryData(agentDir, this.ctx.sessionManager.getCwd());
436
438
  await this.ctx.session.refreshBaseSystemPrompt();
437
439
  this.ctx.showStatus("Memory data cleared and system prompt refreshed.");
438
440
  } catch (error) {
@@ -249,6 +249,13 @@ export class EventController {
249
249
  if (details?.todos) {
250
250
  this.ctx.setTodos(details.todos);
251
251
  }
252
+ } else if (event.toolName === "todo_write" && event.isError) {
253
+ const textContent = event.result.content.find(
254
+ (content: { type: string; text?: string }) => content.type === "text",
255
+ )?.text;
256
+ this.ctx.showWarning(
257
+ `Todo update failed${textContent ? `: ${textContent}` : ". Progress may be stale until todo_write succeeds."}`,
258
+ );
252
259
  }
253
260
  if (event.toolName === "exit_plan_mode" && !event.isError) {
254
261
  const details = event.result.details as ExitPlanModeDetails | undefined;
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
3
- import { readImageFromClipboard } from "@oh-my-pi/pi-natives";
3
+ import { copyToClipboard, readImageFromClipboard, sanitizeText } from "@oh-my-pi/pi-natives";
4
4
  import { $env } from "@oh-my-pi/pi-utils";
5
5
  import type { SettingPath, SettingValue } from "../../config/settings";
6
6
  import { settings } from "../../config/settings";
@@ -74,6 +74,7 @@ export class InputController {
74
74
  this.ctx.editor.onCtrlG = () => void this.openExternalEditor();
75
75
  this.ctx.editor.onQuestionMark = () => this.ctx.handleHotkeysCommand();
76
76
  this.ctx.editor.onCtrlV = () => this.handleImagePaste();
77
+ this.ctx.editor.onCopyPrompt = () => this.handleCopyPrompt();
77
78
 
78
79
  // Wire up extension shortcuts
79
80
  this.registerExtensionShortcuts();
@@ -112,6 +113,9 @@ export class InputController {
112
113
  for (const key of this.ctx.keybindings.getKeys("followUp")) {
113
114
  this.ctx.editor.setCustomKeyHandler(key, () => void this.handleFollowUp());
114
115
  }
116
+ for (const key of this.ctx.keybindings.getKeys("toggleSTT")) {
117
+ this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handleSTTToggle());
118
+ }
115
119
 
116
120
  this.ctx.editor.onChange = (text: string) => {
117
121
  const wasBashMode = this.ctx.isBashMode;
@@ -348,7 +352,6 @@ export class InputController {
348
352
  void this.ctx.shutdown();
349
353
  return;
350
354
  }
351
-
352
355
  // Handle MCP server management commands
353
356
  if (text === "/mcp" || text.startsWith("/mcp ")) {
354
357
  this.ctx.editor.addToHistory(text);
@@ -664,6 +667,24 @@ export class InputController {
664
667
  }
665
668
  }
666
669
 
670
+ /** Copy current prompt text to system clipboard. */
671
+ handleCopyPrompt(): void {
672
+ const text = this.ctx.editor.getText();
673
+ if (!text) {
674
+ this.ctx.showStatus("Nothing to copy");
675
+ return;
676
+ }
677
+ copyToClipboard(text)
678
+ .then(() => {
679
+ const sanitized = sanitizeText(text);
680
+ const preview = sanitized.length > 30 ? `${sanitized.slice(0, 30)}...` : sanitized;
681
+ this.ctx.showStatus(`Copied: ${preview}`);
682
+ })
683
+ .catch(() => {
684
+ this.ctx.showWarning("Failed to copy to clipboard");
685
+ });
686
+ }
687
+
667
688
  cycleThinkingLevel(): void {
668
689
  const newLevel = this.ctx.session.cycleThinkingLevel();
669
690
  if (newLevel === undefined) {
@@ -20,6 +20,7 @@ import { UserMessageSelectorComponent } from "../../modes/components/user-messag
20
20
  import {
21
21
  getAvailableThemes,
22
22
  getSymbolTheme,
23
+ previewTheme,
23
24
  setColorBlindMode,
24
25
  setSymbolPreset,
25
26
  setTheme,
@@ -71,13 +72,14 @@ export class SelectorController {
71
72
  },
72
73
  {
73
74
  onChange: (id, value) => this.handleSettingChange(id, value),
74
- onThemePreview: themeName => {
75
- setTheme(themeName, true).then(result => {
76
- if (result.success) {
77
- this.ctx.ui.invalidate();
78
- this.ctx.ui.requestRender();
79
- }
80
- });
75
+ onThemePreview: async themeName => {
76
+ const result = await previewTheme(themeName);
77
+ if (result.success) {
78
+ this.ctx.statusLine.invalidate();
79
+ this.ctx.updateEditorTopBorder();
80
+ this.ctx.ui.invalidate();
81
+ this.ctx.ui.requestRender();
82
+ }
81
83
  },
82
84
  onStatusLinePreview: previewSettings => {
83
85
  // Update status line with preview settings