@oh-my-pi/pi-coding-agent 12.4.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 (47) hide show
  1. package/CHANGELOG.md +51 -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 +4 -1
  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/main.ts +7 -1
  20. package/src/modes/components/custom-editor.ts +8 -0
  21. package/src/modes/components/settings-selector.ts +29 -14
  22. package/src/modes/controllers/command-controller.ts +2 -0
  23. package/src/modes/controllers/event-controller.ts +7 -0
  24. package/src/modes/controllers/input-controller.ts +23 -2
  25. package/src/modes/controllers/selector-controller.ts +9 -7
  26. package/src/modes/interactive-mode.ts +84 -1
  27. package/src/modes/rpc/rpc-client.ts +7 -0
  28. package/src/modes/rpc/rpc-mode.ts +8 -0
  29. package/src/modes/rpc/rpc-types.ts +2 -0
  30. package/src/modes/theme/theme.ts +163 -7
  31. package/src/modes/types.ts +1 -0
  32. package/src/patch/hashline.ts +2 -1
  33. package/src/patch/shared.ts +44 -13
  34. package/src/prompts/system/plan-mode-approved.md +5 -0
  35. package/src/prompts/system/subagent-system-prompt.md +1 -0
  36. package/src/prompts/system/system-prompt.md +10 -0
  37. package/src/prompts/tools/todo-write.md +3 -1
  38. package/src/sdk.ts +82 -9
  39. package/src/session/agent-session.ts +137 -29
  40. package/src/stt/downloader.ts +71 -0
  41. package/src/stt/index.ts +3 -0
  42. package/src/stt/recorder.ts +351 -0
  43. package/src/stt/setup.ts +52 -0
  44. package/src/stt/stt-controller.ts +160 -0
  45. package/src/stt/transcribe.py +70 -0
  46. package/src/stt/transcriber.ts +91 -0
  47. package/src/task/executor.ts +10 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,56 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [12.5.0] - 2026-02-15
6
+ ### Breaking Changes
7
+
8
+ - Replaced `theme` setting with `theme.dark` and `theme.light` (auto-migrated)
9
+
10
+ ### Added
11
+
12
+ - Added `previewTheme()` function for non-destructive theme preview during settings browsing
13
+ - Added animated microphone icon with color cycling during voice recording
14
+ - Added support for discovering skills via symbolic links in skill directories
15
+ - Added `abort_and_prompt` RPC command for atomic abort-and-reprompt without race conditions ([#357](https://github.com/can1357/oh-my-pi/pull/357))
16
+ - Added automatic dark/light theme switching via SIGWINCH with separate `theme.dark`/`theme.light` settings, replacing the single `theme` setting ([#65](https://github.com/can1357/oh-my-pi/issues/65))
17
+ - Added speech-to-text (STT) feature with `Alt+H` keybinding and `/stt` slash command
18
+ - Added cross-platform audio recording: SoX, FFmpeg, arecord (Linux), PowerShell mciSendString (Windows fallback)
19
+ - Added recording tool fallback chain — automatically tries each available tool in order
20
+ - Added Python openai-whisper integration for transcription with automatic `pip install`
21
+ - Added custom WAV-to-numpy pipeline in `transcribe.py` bypassing ffmpeg dependency
22
+ - Added STT settings: `stt.enabled`, `stt.language`, `stt.modelName`
23
+ - Added STT status line segment showing recording/transcribing state
24
+ - Added `/stt` command with `on`, `off`, `status`, `setup` subcommands
25
+ - Added auto-download of recording tools (best-effort FFmpeg via winget on Windows)
26
+ - Added interactive debug log viewer with selection, copy, and expand/collapse controls
27
+ - Added inline filtering and count display to the debug log viewer
28
+ - Added pid filter toggle and load-older pagination controls to the debug log viewer
29
+ - Enabled loading older debug logs from archived files in viewer
30
+ - Added file hyperlinks for debug report paths in viewer
31
+
32
+ ### Changed
33
+
34
+ - Changed theme preview to support asynchronous theme loading with request deduplication to prevent race conditions
35
+ - Enhanced theme preview cancellation to restore the previously active theme instead of the last selected value
36
+ - Refactored file discovery to use native glob with gitignore support instead of manual directory traversal, improving performance and consistency
37
+ - Updated dependencies: glob to ^13.0.3, marked to ^17.0.2, puppeteer to ^24.37.3
38
+ - Optimized skill and file discovery using native glob (Rust ignore crate) — reduces startup time by ~80% (1254ms → 6ms for skills)
39
+ - Enhanced hashline reference parsing to handle prefixes like `>>>` and `>>` in line references
40
+ - Strengthened type safety in hashline edit formatting with defensive null checks for incomplete edits
41
+ - Changed STT status messages to display via state change callbacks instead of explicit status calls
42
+ - Changed cursor visibility behavior during voice recording to hide hardware and terminal cursors
43
+
44
+ ### Removed
45
+
46
+ - Removed dedicated STT status line segment in favor of animated cursor-based feedback
47
+
48
+ ### Fixed
49
+
50
+ - Fixed theme preview updates being applied out-of-order when rapidly browsing theme options
51
+ - Fixed skill discovery to correctly extract skill names from directory paths when frontmatter name is missing
52
+ - Fixed `session.abort()` not clearing `promptInFlight` flag due to microtask ordering, which blocked subsequent prompts
53
+ - Sanitized debug log display to strip control codes, normalize tabs, and trim width
54
+
5
55
  ## [12.4.0] - 2026-02-14
6
56
  ### Changed
7
57
 
@@ -62,6 +112,7 @@
62
112
  - Improved error reporting in fetch tool to include HTTP status codes when URL fetching fails
63
113
  - Fixed fetch tool to preserve actual response metadata (finalUrl, contentType) instead of defaults when requests fail
64
114
 
115
+ ||||||| parent of a70a34c8b (fix(coding-agent/debug): Sanitized debug log rendering)
65
116
  ## [12.1.0] - 2026-02-13
66
117
 
67
118
  ### Added
@@ -324,19 +324,34 @@ async execute(toolCallId, params, onUpdate, ctx, signal) {
324
324
  Tools can implement `onSession` to react to session changes:
325
325
 
326
326
  ```typescript
327
- interface CustomToolSessionEvent {
328
- reason: "start" | "switch" | "branch" | "tree" | "shutdown";
329
- previousSessionFile: string | undefined;
330
- }
327
+ type CustomToolSessionEvent =
328
+ | { reason: "start" | "switch" | "branch" | "tree" | "shutdown"; previousSessionFile: string | undefined }
329
+ | { reason: "auto_compaction_start"; trigger: "threshold" | "overflow" }
330
+ | {
331
+ reason: "auto_compaction_end";
332
+ result: CompactionResult | undefined;
333
+ aborted: boolean;
334
+ willRetry: boolean;
335
+ errorMessage?: string;
336
+ }
337
+ | { reason: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
338
+ | { reason: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
339
+ | { reason: "ttsr_triggered"; rules: Rule[] }
340
+ | { reason: "todo_reminder"; todos: TodoItem[]; attempt: number; maxAttempts: number };
331
341
  ```
332
342
 
333
343
  **Reasons:**
334
-
335
- - `start`: Initial session load on startup
344
+ - `start`: Initial session load (fresh start or resuming an existing session) - use to reconstruct state from session entries
336
345
  - `switch`: User started a new session (`/new`) or switched to a different session (`/resume`)
337
346
  - `branch`: User branched from a previous message (`/branch`)
338
347
  - `tree`: User navigated to a different point in the session tree (`/tree`)
339
348
  - `shutdown`: Process is exiting (Ctrl+C, Ctrl+D, or SIGTERM) - use to cleanup resources
349
+ - `auto_compaction_start`: Auto-compaction kicked off (`threshold` or `overflow`)
350
+ - `auto_compaction_end`: Auto-compaction finished (includes result/abort/error metadata)
351
+ - `auto_retry_start`: Automatic retry scheduled after an assistant error
352
+ - `auto_retry_end`: Automatic retry completed/failed/cancelled
353
+ - `ttsr_triggered`: Time-travel stream rule interrupted generation
354
+ - `todo_reminder`: Todo reminder fired with outstanding items
340
355
 
341
356
  To check if a session is fresh (no messages), use `ctx.sessionManager.getEntries().length === 0`.
342
357
 
@@ -499,6 +499,26 @@ pi.on("turn_end", async (event, ctx) => {
499
499
 
500
500
  **Examples:** [plan-mode.ts](../examples/extensions/plan-mode.ts)
501
501
 
502
+ #### Runtime reliability events
503
+
504
+ Fired for internal recovery/continuation mechanics:
505
+
506
+ - `auto_compaction_start` / `auto_compaction_end`
507
+ - `auto_retry_start` / `auto_retry_end`
508
+ - `ttsr_triggered`
509
+ - `todo_reminder`
510
+
511
+ ```typescript
512
+ pi.on("todo_reminder", async (event, _ctx) => {
513
+ // event.todos, event.attempt, event.maxAttempts
514
+ });
515
+
516
+ pi.on("auto_retry_start", async (event, _ctx) => {
517
+ // event.attempt, event.maxAttempts, event.delayMs, event.errorMessage
518
+ });
519
+ ```
520
+
521
+
502
522
  #### context
503
523
 
504
524
  Fired before each LLM call. Modify messages non-destructively.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "12.4.0",
3
+ "version": "12.5.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "bin": {
@@ -78,31 +78,31 @@
78
78
  "scripts": {
79
79
  "check": "tsgo -p tsconfig.json",
80
80
  "format-prompts": "bun scripts/format-prompts.ts",
81
- "build:binary": "cd ../.. && bun --cwd=packages/natives run embed:native && bun build --compile --define PI_COMPILED=true --root . ./packages/coding-agent/src/cli.ts --outfile packages/coding-agent/dist/omp && bun --cwd=packages/natives run embed:native --reset",
81
+ "build:binary": "cd ../.. && bun --cwd=packages/stats scripts/generate-client-bundle.ts && bun --cwd=packages/natives run embed:native && bun build --compile --define PI_COMPILED=true --root . ./packages/coding-agent/src/cli.ts --outfile packages/coding-agent/dist/omp && bun --cwd=packages/natives run embed:native --reset && bun --cwd=packages/stats scripts/generate-client-bundle.ts --reset",
82
82
  "generate-template": "bun scripts/generate-template.ts",
83
83
  "test": "bun test"
84
84
  },
85
85
  "dependencies": {
86
86
  "@mozilla/readability": "0.6.0",
87
- "@oh-my-pi/omp-stats": "12.4.0",
88
- "@oh-my-pi/pi-agent-core": "12.4.0",
89
- "@oh-my-pi/pi-ai": "12.4.0",
90
- "@oh-my-pi/pi-natives": "12.4.0",
91
- "@oh-my-pi/pi-tui": "12.4.0",
92
- "@oh-my-pi/pi-utils": "12.4.0",
87
+ "@oh-my-pi/omp-stats": "12.5.0",
88
+ "@oh-my-pi/pi-agent-core": "12.5.0",
89
+ "@oh-my-pi/pi-ai": "12.5.0",
90
+ "@oh-my-pi/pi-natives": "12.5.0",
91
+ "@oh-my-pi/pi-tui": "12.5.0",
92
+ "@oh-my-pi/pi-utils": "12.5.0",
93
93
  "@sinclair/typebox": "^0.34.48",
94
94
  "@xterm/headless": "^6.0.0",
95
- "ajv": "^8.17.1",
95
+ "ajv": "^8.18.0",
96
96
  "chalk": "^5.6.2",
97
97
  "diff": "^8.0.3",
98
98
  "file-type": "^21.3.0",
99
- "glob": "^13.0.1",
99
+ "glob": "^13.0.3",
100
100
  "handlebars": "^4.7.8",
101
101
  "ignore": "^7.0.5",
102
102
  "linkedom": "^0.18.12",
103
- "marked": "^17.0.1",
103
+ "marked": "^17.0.2",
104
104
  "node-html-parser": "^7.0.2",
105
- "puppeteer": "^24.37.1",
105
+ "puppeteer": "^24.37.3",
106
106
  "smol-toml": "^1.6.0",
107
107
  "zod": "^4.3.6"
108
108
  },
@@ -9,7 +9,7 @@ import { $ } from "bun";
9
9
  import chalk from "chalk";
10
10
  import { theme } from "../modes/theme/theme";
11
11
 
12
- export type SetupComponent = "python";
12
+ export type SetupComponent = "python" | "stt";
13
13
 
14
14
  export interface SetupCommandArgs {
15
15
  component: SetupComponent;
@@ -19,7 +19,7 @@ export interface SetupCommandArgs {
19
19
  };
20
20
  }
21
21
 
22
- const VALID_COMPONENTS: SetupComponent[] = ["python"];
22
+ const VALID_COMPONENTS: SetupComponent[] = ["python", "stt"];
23
23
 
24
24
  const PYTHON_PACKAGES = ["jupyter_kernel_gateway", "ipykernel"];
25
25
  const MANAGED_PYTHON_ENV = getPythonEnvDir();
@@ -206,6 +206,9 @@ export async function runSetupCommand(cmd: SetupCommandArgs): Promise<void> {
206
206
  case "python":
207
207
  await handlePythonSetup(cmd.flags);
208
208
  break;
209
+ case "stt":
210
+ await handleSttSetup(cmd.flags);
211
+ break;
209
212
  }
210
213
  }
211
214
 
@@ -292,6 +295,60 @@ async function handlePythonSetup(flags: { json?: boolean; check?: boolean }): Pr
292
295
  }
293
296
  }
294
297
 
298
+ async function handleSttSetup(flags: { json?: boolean; check?: boolean }): Promise<void> {
299
+ const { checkDependencies, formatDependencyStatus } = await import("../stt/setup");
300
+ const status = await checkDependencies();
301
+
302
+ if (flags.json) {
303
+ console.log(JSON.stringify(status, null, 2));
304
+ if (!status.recorder.available || !status.python.available || !status.whisper.available) process.exit(1);
305
+ return;
306
+ }
307
+
308
+ console.log(formatDependencyStatus(status));
309
+
310
+ if (status.recorder.available && status.python.available && status.whisper.available) {
311
+ console.log(chalk.green(`\n${theme.status.success} Speech-to-text is ready`));
312
+ return;
313
+ }
314
+
315
+ if (flags.check) {
316
+ process.exit(1);
317
+ }
318
+
319
+ if (!status.python.available) {
320
+ console.error(chalk.red(`\n${theme.status.error} Python not found`));
321
+ console.error(chalk.dim("Install Python 3.8+ and ensure it's in your PATH"));
322
+ process.exit(1);
323
+ }
324
+
325
+ if (!status.recorder.available) {
326
+ console.error(chalk.yellow(`\n${theme.status.warning} No recording tool found`));
327
+ console.error(chalk.dim(status.recorder.installHint));
328
+ }
329
+
330
+ if (!status.whisper.available) {
331
+ console.log(chalk.dim(`\nInstalling openai-whisper...`));
332
+ const { resolvePython } = await import("../stt/transcriber");
333
+ const pythonCmd = resolvePython()!;
334
+ const result = await $`${pythonCmd} -m pip install -q openai-whisper`.nothrow();
335
+ if (result.exitCode !== 0) {
336
+ console.error(chalk.red(`\n${theme.status.error} Failed to install openai-whisper`));
337
+ console.error(chalk.dim("Try manually: pip install openai-whisper"));
338
+ process.exit(1);
339
+ }
340
+ }
341
+
342
+ const recheck = await checkDependencies();
343
+ if (recheck.recorder.available && recheck.python.available && recheck.whisper.available) {
344
+ console.log(chalk.green(`\n${theme.status.success} Speech-to-text is ready`));
345
+ } else {
346
+ console.error(chalk.red(`\n${theme.status.error} Setup incomplete`));
347
+ console.log(formatDependencyStatus(recheck));
348
+ process.exit(1);
349
+ }
350
+ }
351
+
295
352
  /**
296
353
  * Print setup command help.
297
354
  */
@@ -303,6 +360,7 @@ ${chalk.bold("Usage:")}
303
360
 
304
361
  ${chalk.bold("Components:")}
305
362
  python Install Jupyter kernel dependencies for Python code execution
363
+ stt Install speech-to-text dependencies (openai-whisper, recording tools)
306
364
  Packages: ${PYTHON_PACKAGES.join(", ")}
307
365
 
308
366
  ${chalk.bold("Options:")}
@@ -311,6 +369,8 @@ ${chalk.bold("Options:")}
311
369
 
312
370
  ${chalk.bold("Examples:")}
313
371
  ${APP_NAME} setup python Install Python execution dependencies
372
+ ${APP_NAME} setup stt Install speech-to-text dependencies
373
+ ${APP_NAME} setup stt --check Check if STT dependencies are available
314
374
  ${APP_NAME} setup python --check Check if Python execution is available
315
375
  `);
316
376
  }
@@ -5,7 +5,7 @@ import { Args, Command, Flags, renderCommandHelp } from "@oh-my-pi/pi-utils/cli"
5
5
  import { runSetupCommand, type SetupCommandArgs, type SetupComponent } from "../cli/setup-cli";
6
6
  import { initTheme } from "../modes/theme/theme";
7
7
 
8
- const COMPONENTS: SetupComponent[] = ["python"];
8
+ const COMPONENTS: SetupComponent[] = ["python", "stt"];
9
9
 
10
10
  export default class Setup extends Command {
11
11
  static description = "Install dependencies for optional features";
@@ -35,7 +35,8 @@ export type AppAction =
35
35
  | "newSession"
36
36
  | "tree"
37
37
  | "fork"
38
- | "resume";
38
+ | "resume"
39
+ | "toggleSTT";
39
40
 
40
41
  /**
41
42
  * All configurable actions.
@@ -74,6 +75,7 @@ export const DEFAULT_APP_KEYBINDINGS: Record<AppAction, KeyId | KeyId[]> = {
74
75
  tree: [],
75
76
  fork: [],
76
77
  resume: [],
78
+ toggleSTT: "alt+h",
77
79
  };
78
80
 
79
81
  /**
@@ -107,6 +109,7 @@ const APP_ACTIONS: AppAction[] = [
107
109
  "tree",
108
110
  "fork",
109
111
  "resume",
112
+ "toggleSTT",
110
113
  ];
111
114
 
112
115
  function isAppAction(action: string): action is AppAction {
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * The Settings singleton provides type-safe path-based access:
9
9
  * settings.get("compaction.enabled") // => boolean
10
- * settings.set("theme", "dark") // sync, saves in background
10
+ * settings.set("theme.dark", "titanium") // sync, saves in background
11
11
  */
12
12
 
13
13
  // ═══════════════════════════════════════════════════════════════════════════
@@ -141,10 +141,25 @@ export const SETTINGS_SCHEMA = {
141
141
  // Top-level settings
142
142
  // ─────────────────────────────────────────────────────────────────────────
143
143
  lastChangelogVersion: { type: "string", default: undefined },
144
- theme: {
144
+ "theme.dark": {
145
145
  type: "string",
146
- default: undefined,
147
- ui: { tab: "display", label: "Theme", description: "Color theme for the interface", submenu: true },
146
+ default: "titanium",
147
+ ui: {
148
+ tab: "display",
149
+ label: "Dark theme",
150
+ description: "Theme used when terminal has dark background",
151
+ submenu: true,
152
+ },
153
+ },
154
+ "theme.light": {
155
+ type: "string",
156
+ default: "light",
157
+ ui: {
158
+ tab: "display",
159
+ label: "Light theme",
160
+ description: "Theme used when terminal has light background",
161
+ submenu: true,
162
+ },
148
163
  },
149
164
  symbolPreset: {
150
165
  type: "enum",
@@ -756,6 +771,36 @@ export const SETTINGS_SCHEMA = {
756
771
  },
757
772
  },
758
773
 
774
+ // ─────────────────────────────────────────────────────────────────────────
775
+ // STT settings
776
+ // ─────────────────────────────────────────────────────────────────────────
777
+ "stt.enabled": {
778
+ type: "boolean",
779
+ default: false,
780
+ ui: { tab: "input", label: "Speech-to-text", description: "Enable speech-to-text input via microphone" },
781
+ },
782
+ "stt.language": {
783
+ type: "string",
784
+ default: "en",
785
+ ui: {
786
+ tab: "input",
787
+ label: "STT language",
788
+ description: "Language code for transcription (e.g., en, es, fr)",
789
+ submenu: true,
790
+ },
791
+ },
792
+ "stt.modelName": {
793
+ type: "enum",
794
+ values: ["tiny", "tiny.en", "base", "base.en", "small", "small.en", "medium", "medium.en", "large"] as const,
795
+ default: "base.en",
796
+ ui: {
797
+ tab: "input",
798
+ label: "STT model",
799
+ description: "Whisper model size (larger = more accurate but slower)",
800
+ submenu: true,
801
+ },
802
+ },
803
+
759
804
  // ─────────────────────────────────────────────────────────────────────────
760
805
  // Edit settings
761
806
  // ─────────────────────────────────────────────────────────────────────────
@@ -1061,6 +1106,14 @@ export interface ThinkingBudgetsSettings {
1061
1106
  high: number;
1062
1107
  }
1063
1108
 
1109
+ export interface SttSettings {
1110
+ enabled: boolean;
1111
+ language: string | undefined;
1112
+ modelName: string;
1113
+ whisperPath: string | undefined;
1114
+ modelPath: string | undefined;
1115
+ }
1116
+
1064
1117
  export interface BashInterceptorRule {
1065
1118
  pattern: string;
1066
1119
  flags?: string;
@@ -1081,6 +1134,7 @@ export interface GroupTypeMap {
1081
1134
  exa: ExaSettings;
1082
1135
  statusLine: StatusLineSettings;
1083
1136
  thinkingBudgets: ThinkingBudgetsSettings;
1137
+ stt: SttSettings;
1084
1138
  modelRoles: Record<string, string>;
1085
1139
  }
1086
1140
 
@@ -5,7 +5,7 @@
5
5
  * import { settings } from "./settings";
6
6
  *
7
7
  * const enabled = settings.get("compaction.enabled"); // sync read
8
- * settings.set("theme", "dark"); // sync write, saves in background
8
+ * settings.set("theme.dark", "titanium"); // sync write, saves in background
9
9
  *
10
10
  * For tests:
11
11
  * const isolated = Settings.isolated({ "compaction.enabled": false });
@@ -19,7 +19,7 @@ import { YAML } from "bun";
19
19
  import { type Settings as SettingsCapabilityItem, settingsCapability } from "../capability/settings";
20
20
  import type { ModelRole } from "../config/model-registry";
21
21
  import { loadCapability } from "../discovery";
22
- import { setColorBlindMode, setSymbolPreset, setTheme } from "../modes/theme/theme";
22
+ import { isLightTheme, setAutoThemeMapping, setColorBlindMode, setSymbolPreset } from "../modes/theme/theme";
23
23
  import { type EditMode, normalizeEditMode } from "../patch";
24
24
  import { AgentStorage } from "../session/agent-storage";
25
25
  import { withFileLock } from "./file-lock";
@@ -81,7 +81,7 @@ export interface SettingsOptions {
81
81
  /**
82
82
  * Parse a dotted path into segments.
83
83
  * "compaction.enabled" → ["compaction", "enabled"]
84
- * "theme" → ["theme"]
84
+ * "theme.dark" → ["theme", "dark"]
85
85
  */
86
86
  function parsePath(path: string): string[] {
87
87
  return path.split(".");
@@ -531,6 +531,19 @@ export class Settings {
531
531
  }
532
532
  }
533
533
 
534
+ // Migrate old flat "theme" string to nested theme.dark/theme.light
535
+ if (typeof raw.theme === "string") {
536
+ const oldTheme = raw.theme;
537
+ if (oldTheme === "light" || oldTheme === "dark") {
538
+ // Built-in defaults — just remove, let new defaults apply
539
+ delete raw.theme;
540
+ } else {
541
+ // Custom theme — detect luminance to place in correct slot
542
+ const slot = isLightTheme(oldTheme) ? "light" : "dark";
543
+ raw.theme = { [slot]: oldTheme };
544
+ }
545
+ }
546
+
534
547
  return raw;
535
548
  }
536
549
 
@@ -628,13 +641,14 @@ export class Settings {
628
641
  type SettingHook<P extends SettingPath> = (value: SettingValue<P>, prev: SettingValue<P>) => void;
629
642
 
630
643
  const SETTING_HOOKS: Partial<Record<SettingPath, SettingHook<any>>> = {
631
- theme: value => {
632
- // Theme loading is async, but we call it synchronously here.
633
- // The hook fires immediately, and the theme system handles async loading internally.
644
+ "theme.dark": value => {
634
645
  if (typeof value === "string") {
635
- setTheme(value, false).catch(err => {
636
- logger.warn("Settings: theme hook failed", { theme: value, error: String(err) });
637
- });
646
+ setAutoThemeMapping("dark", value);
647
+ }
648
+ },
649
+ "theme.light": value => {
650
+ if (typeof value === "string") {
651
+ setAutoThemeMapping("light", value);
638
652
  }
639
653
  },
640
654
  symbolPreset: value => {
@@ -4,6 +4,7 @@
4
4
  * Provides tools for debugging, bug report generation, and system diagnostics.
5
5
  */
6
6
  import * as fs from "node:fs/promises";
7
+ import * as url from "node:url";
7
8
  import { getWorkProfile } from "@oh-my-pi/pi-natives";
8
9
  import { Container, Loader, type SelectItem, SelectList, Spacer, Text } from "@oh-my-pi/pi-tui";
9
10
  import { getSessionsDir } from "@oh-my-pi/pi-utils/dirs";
@@ -11,8 +12,9 @@ import { DynamicBorder } from "../modes/components/dynamic-border";
11
12
  import { getSelectListTheme, getSymbolTheme, theme } from "../modes/theme/theme";
12
13
  import type { InteractiveModeContext } from "../modes/types";
13
14
  import { openPath } from "../utils/open";
15
+ import { DebugLogViewerComponent } from "./log-viewer";
14
16
  import { generateHeapSnapshotData, type ProfilerSession, startCpuProfile } from "./profiler";
15
- import { clearArtifactCache, createReportBundle, getArtifactCacheStats, getRecentLogs } from "./report-bundle";
17
+ import { clearArtifactCache, createDebugLogSource, createReportBundle, getArtifactCacheStats } from "./report-bundle";
16
18
  import { collectSystemInfo, formatSystemInfo } from "./system-info";
17
19
 
18
20
  /** Debug menu options */
@@ -27,6 +29,11 @@ const DEBUG_MENU_ITEMS: SelectItem[] = [
27
29
  { value: "clear-cache", label: "Clear: artifact cache", description: "Remove old session artifacts" },
28
30
  ];
29
31
 
32
+ const formatFileHyperlink = (path: string): string => {
33
+ const fileUrl = url.pathToFileURL(path).href;
34
+ return `\x1b]8;;${fileUrl}\x07${path}\x1b]8;;\x07`;
35
+ };
36
+
30
37
  /**
31
38
  * Debug selector component.
32
39
  */
@@ -159,7 +166,7 @@ export class DebugSelectorComponent extends Container {
159
166
  this.ctx.chatContainer.addChild(
160
167
  new Text(theme.fg("success", `${theme.status.success} Performance report saved`), 1, 0),
161
168
  );
162
- this.ctx.chatContainer.addChild(new Text(theme.fg("dim", result.path), 1, 0));
169
+ this.ctx.chatContainer.addChild(new Text(theme.fg("dim", formatFileHyperlink(result.path)), 1, 0));
163
170
  this.ctx.chatContainer.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0));
164
171
  } catch (err) {
165
172
  loader.stop();
@@ -220,7 +227,7 @@ export class DebugSelectorComponent extends Container {
220
227
  this.ctx.chatContainer.addChild(
221
228
  new Text(theme.fg("success", `${theme.status.success} Report bundle saved`), 1, 0),
222
229
  );
223
- this.ctx.chatContainer.addChild(new Text(theme.fg("dim", result.path), 1, 0));
230
+ this.ctx.chatContainer.addChild(new Text(theme.fg("dim", formatFileHyperlink(result.path)), 1, 0));
224
231
  this.ctx.chatContainer.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0));
225
232
  } catch (err) {
226
233
  loader.stop();
@@ -259,7 +266,7 @@ export class DebugSelectorComponent extends Container {
259
266
  this.ctx.chatContainer.addChild(
260
267
  new Text(theme.fg("success", `${theme.status.success} Memory report saved`), 1, 0),
261
268
  );
262
- this.ctx.chatContainer.addChild(new Text(theme.fg("dim", result.path), 1, 0));
269
+ this.ctx.chatContainer.addChild(new Text(theme.fg("dim", formatFileHyperlink(result.path)), 1, 0));
263
270
  this.ctx.chatContainer.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0));
264
271
  } catch (err) {
265
272
  loader.stop();
@@ -272,26 +279,26 @@ export class DebugSelectorComponent extends Container {
272
279
 
273
280
  async #handleViewLogs(): Promise<void> {
274
281
  try {
275
- const logs = await getRecentLogs(50);
276
- if (!logs) {
282
+ const logSource = await createDebugLogSource();
283
+ const logs = await logSource.getInitialText();
284
+ if (!logs && !logSource.hasOlderLogs()) {
277
285
  this.ctx.showWarning("No log entries found for today.");
278
286
  return;
279
287
  }
280
288
 
281
- this.ctx.chatContainer.addChild(new Spacer(1));
282
- this.ctx.chatContainer.addChild(new DynamicBorder());
283
- this.ctx.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Recent Logs")), 1, 0));
284
- this.ctx.chatContainer.addChild(new Spacer(1));
285
-
286
- // Display logs with dim styling
287
- const lines = logs.split("\n").slice(-50);
288
- for (const line of lines) {
289
- if (line.trim()) {
290
- this.ctx.chatContainer.addChild(new Text(theme.fg("dim", line), 1, 0));
291
- }
292
- }
289
+ const viewer = new DebugLogViewerComponent({
290
+ logs,
291
+ terminalRows: this.ctx.ui.terminal.rows,
292
+ onExit: () => this.ctx.showDebugSelector(),
293
+ onStatus: message => this.ctx.showStatus(message, { dim: true }),
294
+ onError: message => this.ctx.showError(message),
295
+ onUpdate: () => this.ctx.ui.requestRender(),
296
+ logSource,
297
+ });
293
298
 
294
- this.ctx.chatContainer.addChild(new DynamicBorder());
299
+ this.ctx.editorContainer.clear();
300
+ this.ctx.editorContainer.addChild(viewer);
301
+ this.ctx.ui.setFocus(viewer);
295
302
  } catch (err) {
296
303
  this.ctx.showError(`Failed to read logs: ${err instanceof Error ? err.message : String(err)}`);
297
304
  }
@@ -0,0 +1,60 @@
1
+ import { sanitizeText } from "@oh-my-pi/pi-natives";
2
+ import { replaceTabs, truncateToWidth } from "../tools/render-utils";
3
+
4
+ export function formatDebugLogLine(line: string, maxWidth: number): string {
5
+ const sanitized = sanitizeText(line);
6
+ const normalized = replaceTabs(sanitized);
7
+ const width = Math.max(1, maxWidth);
8
+ return truncateToWidth(normalized, width);
9
+ }
10
+
11
+ export function formatDebugLogExpandedLines(line: string, maxWidth: number): string[] {
12
+ const sanitized = sanitizeText(line);
13
+ const normalized = replaceTabs(sanitized);
14
+ const width = Math.max(1, maxWidth);
15
+
16
+ if (normalized.length === 0) {
17
+ return [""];
18
+ }
19
+
20
+ return normalized
21
+ .split("\n")
22
+ .flatMap(segment => Bun.wrapAnsi(segment, width, { hard: true, trim: false, wordWrap: true }).split("\n"));
23
+ }
24
+
25
+ export function parseDebugLogTimestampMs(line: string): number | undefined {
26
+ try {
27
+ const parsed: unknown = JSON.parse(line);
28
+ if (!parsed || typeof parsed !== "object") {
29
+ return undefined;
30
+ }
31
+
32
+ const timestamp = (parsed as { timestamp?: unknown }).timestamp;
33
+ if (typeof timestamp !== "string") {
34
+ return undefined;
35
+ }
36
+
37
+ const timestampMs = Date.parse(timestamp);
38
+ return Number.isFinite(timestampMs) ? timestampMs : undefined;
39
+ } catch {
40
+ return undefined;
41
+ }
42
+ }
43
+
44
+ export function parseDebugLogPid(line: string): number | undefined {
45
+ try {
46
+ const parsed: unknown = JSON.parse(line);
47
+ if (!parsed || typeof parsed !== "object") {
48
+ return undefined;
49
+ }
50
+
51
+ const pid = (parsed as { pid?: unknown }).pid;
52
+ if (typeof pid !== "number") {
53
+ return undefined;
54
+ }
55
+
56
+ return Number.isFinite(pid) ? pid : undefined;
57
+ } catch {
58
+ return undefined;
59
+ }
60
+ }