@oh-my-pi/pi-coding-agent 8.0.16 → 8.1.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 (166) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/package.json +14 -11
  3. package/scripts/generate-wasm-b64.ts +24 -0
  4. package/src/capability/context-file.ts +1 -1
  5. package/src/capability/extension-module.ts +1 -1
  6. package/src/capability/extension.ts +1 -1
  7. package/src/capability/hook.ts +1 -1
  8. package/src/capability/instruction.ts +1 -1
  9. package/src/capability/mcp.ts +1 -1
  10. package/src/capability/prompt.ts +1 -1
  11. package/src/capability/rule.ts +1 -1
  12. package/src/capability/settings.ts +1 -1
  13. package/src/capability/skill.ts +1 -1
  14. package/src/capability/slash-command.ts +1 -1
  15. package/src/capability/ssh.ts +1 -1
  16. package/src/capability/system-prompt.ts +1 -1
  17. package/src/capability/tool.ts +1 -1
  18. package/src/cli/args.ts +1 -1
  19. package/src/cli/plugin-cli.ts +1 -5
  20. package/src/commit/agentic/agent.ts +309 -0
  21. package/src/commit/agentic/fallback.ts +96 -0
  22. package/src/commit/agentic/index.ts +359 -0
  23. package/src/commit/agentic/prompts/analyze-file.md +22 -0
  24. package/src/commit/agentic/prompts/session-user.md +26 -0
  25. package/src/commit/agentic/prompts/split-confirm.md +1 -0
  26. package/src/commit/agentic/prompts/system.md +40 -0
  27. package/src/commit/agentic/state.ts +74 -0
  28. package/src/commit/agentic/tools/analyze-file.ts +131 -0
  29. package/src/commit/agentic/tools/git-file-diff.ts +194 -0
  30. package/src/commit/agentic/tools/git-hunk.ts +50 -0
  31. package/src/commit/agentic/tools/git-overview.ts +84 -0
  32. package/src/commit/agentic/tools/index.ts +56 -0
  33. package/src/commit/agentic/tools/propose-changelog.ts +128 -0
  34. package/src/commit/agentic/tools/propose-commit.ts +154 -0
  35. package/src/commit/agentic/tools/recent-commits.ts +81 -0
  36. package/src/commit/agentic/tools/split-commit.ts +284 -0
  37. package/src/commit/agentic/topo-sort.ts +44 -0
  38. package/src/commit/agentic/trivial.ts +51 -0
  39. package/src/commit/agentic/validation.ts +200 -0
  40. package/src/commit/analysis/conventional.ts +169 -0
  41. package/src/commit/analysis/index.ts +4 -0
  42. package/src/commit/analysis/scope.ts +242 -0
  43. package/src/commit/analysis/summary.ts +114 -0
  44. package/src/commit/analysis/validation.ts +66 -0
  45. package/src/commit/changelog/detect.ts +36 -0
  46. package/src/commit/changelog/generate.ts +112 -0
  47. package/src/commit/changelog/index.ts +233 -0
  48. package/src/commit/changelog/parse.ts +44 -0
  49. package/src/commit/cli.ts +93 -0
  50. package/src/commit/git/diff.ts +148 -0
  51. package/src/commit/git/errors.ts +11 -0
  52. package/src/commit/git/index.ts +217 -0
  53. package/src/commit/git/operations.ts +53 -0
  54. package/src/commit/index.ts +5 -0
  55. package/src/commit/map-reduce/.map-phase.ts.kate-swp +0 -0
  56. package/src/commit/map-reduce/index.ts +63 -0
  57. package/src/commit/map-reduce/map-phase.ts +193 -0
  58. package/src/commit/map-reduce/reduce-phase.ts +147 -0
  59. package/src/commit/map-reduce/utils.ts +9 -0
  60. package/src/commit/message.ts +11 -0
  61. package/src/commit/model-selection.ts +84 -0
  62. package/src/commit/pipeline.ts +242 -0
  63. package/src/commit/prompts/analysis-system.md +155 -0
  64. package/src/commit/prompts/analysis-user.md +41 -0
  65. package/src/commit/prompts/changelog-system.md +56 -0
  66. package/src/commit/prompts/changelog-user.md +19 -0
  67. package/src/commit/prompts/file-observer-system.md +26 -0
  68. package/src/commit/prompts/file-observer-user.md +9 -0
  69. package/src/commit/prompts/reduce-system.md +60 -0
  70. package/src/commit/prompts/reduce-user.md +17 -0
  71. package/src/commit/prompts/summary-retry.md +4 -0
  72. package/src/commit/prompts/summary-system.md +52 -0
  73. package/src/commit/prompts/summary-user.md +13 -0
  74. package/src/commit/prompts/types-description.md +2 -0
  75. package/src/commit/types.ts +109 -0
  76. package/src/commit/utils/exclusions.ts +42 -0
  77. package/src/config/file-lock.ts +111 -0
  78. package/src/config/model-registry.ts +16 -7
  79. package/src/config/settings-manager.ts +115 -40
  80. package/src/config.ts +5 -5
  81. package/src/discovery/agents-md.ts +1 -1
  82. package/src/discovery/builtin.ts +1 -1
  83. package/src/discovery/claude.ts +1 -1
  84. package/src/discovery/cline.ts +1 -1
  85. package/src/discovery/codex.ts +1 -1
  86. package/src/discovery/cursor.ts +1 -1
  87. package/src/discovery/gemini.ts +1 -1
  88. package/src/discovery/github.ts +1 -1
  89. package/src/discovery/index.ts +11 -11
  90. package/src/discovery/mcp-json.ts +1 -1
  91. package/src/discovery/ssh.ts +1 -1
  92. package/src/discovery/vscode.ts +1 -1
  93. package/src/discovery/windsurf.ts +1 -1
  94. package/src/extensibility/custom-commands/loader.ts +1 -1
  95. package/src/extensibility/custom-commands/types.ts +1 -1
  96. package/src/extensibility/custom-tools/loader.ts +1 -1
  97. package/src/extensibility/custom-tools/types.ts +1 -1
  98. package/src/extensibility/extensions/loader.ts +1 -1
  99. package/src/extensibility/extensions/types.ts +1 -1
  100. package/src/extensibility/hooks/loader.ts +1 -1
  101. package/src/extensibility/hooks/types.ts +3 -3
  102. package/src/index.ts +10 -10
  103. package/src/ipy/executor.ts +97 -1
  104. package/src/lsp/index.ts +1 -1
  105. package/src/lsp/render.ts +90 -46
  106. package/src/main.ts +16 -3
  107. package/src/mcp/loader.ts +3 -3
  108. package/src/migrations.ts +3 -3
  109. package/src/modes/components/assistant-message.ts +29 -1
  110. package/src/modes/components/tool-execution.ts +5 -3
  111. package/src/modes/components/tree-selector.ts +1 -1
  112. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  113. package/src/modes/controllers/selector-controller.ts +1 -1
  114. package/src/modes/interactive-mode.ts +5 -3
  115. package/src/modes/rpc/rpc-client.ts +1 -1
  116. package/src/modes/rpc/rpc-mode.ts +1 -4
  117. package/src/modes/rpc/rpc-types.ts +1 -1
  118. package/src/modes/theme/mermaid-cache.ts +89 -0
  119. package/src/modes/theme/theme.ts +2 -0
  120. package/src/modes/types.ts +2 -2
  121. package/src/patch/index.ts +3 -9
  122. package/src/patch/shared.ts +33 -5
  123. package/src/prompts/tools/task.md +2 -0
  124. package/src/sdk.ts +60 -22
  125. package/src/session/agent-session.ts +3 -3
  126. package/src/session/agent-storage.ts +32 -28
  127. package/src/session/artifacts.ts +24 -1
  128. package/src/session/auth-storage.ts +25 -10
  129. package/src/session/storage-migration.ts +12 -53
  130. package/src/system-prompt.ts +2 -2
  131. package/src/task/.executor.ts.kate-swp +0 -0
  132. package/src/task/executor.ts +1 -1
  133. package/src/task/index.ts +10 -1
  134. package/src/task/output-manager.ts +94 -0
  135. package/src/task/render.ts +7 -12
  136. package/src/task/worker.ts +1 -1
  137. package/src/tools/ask.ts +35 -13
  138. package/src/tools/bash.ts +80 -87
  139. package/src/tools/calculator.ts +42 -40
  140. package/src/tools/complete.ts +1 -1
  141. package/src/tools/fetch.ts +67 -104
  142. package/src/tools/find.ts +83 -86
  143. package/src/tools/grep.ts +80 -96
  144. package/src/tools/index.ts +10 -7
  145. package/src/tools/ls.ts +39 -65
  146. package/src/tools/notebook.ts +48 -64
  147. package/src/tools/output-utils.ts +1 -1
  148. package/src/tools/python.ts +71 -183
  149. package/src/tools/read.ts +74 -15
  150. package/src/tools/render-utils.ts +1 -15
  151. package/src/tools/ssh.ts +43 -24
  152. package/src/tools/todo-write.ts +27 -15
  153. package/src/tools/write.ts +93 -64
  154. package/src/tui/code-cell.ts +115 -0
  155. package/src/tui/file-list.ts +48 -0
  156. package/src/tui/index.ts +11 -0
  157. package/src/tui/output-block.ts +73 -0
  158. package/src/tui/status-line.ts +40 -0
  159. package/src/tui/tree-list.ts +56 -0
  160. package/src/tui/types.ts +17 -0
  161. package/src/tui/utils.ts +49 -0
  162. package/src/vendor/photon/photon_rs_bg.wasm.b64.js +1 -0
  163. package/src/web/search/auth.ts +1 -1
  164. package/src/web/search/index.ts +1 -1
  165. package/src/web/search/render.ts +119 -163
  166. package/tsconfig.json +0 -42
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // TypeBox helper for string enums (convenience for custom tools)
4
4
  // Re-export from pi-ai which uses the correct enum-based schema format
5
5
  export { StringEnum } from "@oh-my-pi/pi-ai";
6
- export type { FileDiagnosticsResult } from "@oh-my-pi/pi-coding-agent/lsp/index";
6
+ export type { FileDiagnosticsResult } from "@oh-my-pi/pi-coding-agent/lsp";
7
7
  // UI components for extensions
8
8
  export {
9
9
  ArminComponent,
@@ -37,7 +37,7 @@ export {
37
37
  UserMessageComponent,
38
38
  UserMessageSelectorComponent,
39
39
  type VisualTruncateResult,
40
- } from "@oh-my-pi/pi-coding-agent/modes/components/index";
40
+ } from "@oh-my-pi/pi-coding-agent/modes/components";
41
41
  // Theme utilities for custom tools
42
42
  export {
43
43
  getLanguageFromPath,
@@ -88,9 +88,9 @@ export type {
88
88
  ExecResult,
89
89
  LoadedCustomTool,
90
90
  RenderResultOptions,
91
- } from "./extensibility/custom-tools/index";
91
+ } from "./extensibility/custom-tools";
92
92
  // Custom tools
93
- export { CustomToolLoader, discoverAndLoadCustomTools, loadCustomTools } from "./extensibility/custom-tools/index";
93
+ export { CustomToolLoader, discoverAndLoadCustomTools, loadCustomTools } from "./extensibility/custom-tools";
94
94
  export type {
95
95
  AppAction,
96
96
  Extension,
@@ -122,7 +122,7 @@ export type {
122
122
  UserBashEventResult,
123
123
  UserPythonEvent,
124
124
  UserPythonEventResult,
125
- } from "./extensibility/extensions/index";
125
+ } from "./extensibility/extensions";
126
126
  // Extension types and utilities
127
127
  export {
128
128
  discoverAndLoadExtensions,
@@ -135,9 +135,9 @@ export {
135
135
  isLsToolResult,
136
136
  isReadToolResult,
137
137
  isWriteToolResult,
138
- } from "./extensibility/extensions/index";
138
+ } from "./extensibility/extensions";
139
139
  // Hook system types (legacy re-export)
140
- export type * from "./extensibility/hooks/index";
140
+ export type * from "./extensibility/hooks";
141
141
  // Skills
142
142
  export {
143
143
  type LoadSkillsFromDirOptions,
@@ -162,7 +162,7 @@ export {
162
162
  type RpcEventListener,
163
163
  runPrintMode,
164
164
  runRpcMode,
165
- } from "./modes/index";
165
+ } from "./modes";
166
166
  // SDK for programmatic usage
167
167
  export {
168
168
  // Factory
@@ -228,7 +228,7 @@ export {
228
228
  prepareBranchEntries,
229
229
  serializeConversation,
230
230
  shouldCompact,
231
- } from "./session/compaction/index";
231
+ } from "./session/compaction";
232
232
  export { convertToLlm } from "./session/messages";
233
233
  export {
234
234
  type BranchSummaryEntry,
@@ -275,5 +275,5 @@ export {
275
275
  truncateLine,
276
276
  truncateTail,
277
277
  type WriteToolDetails,
278
- } from "./tools/index";
278
+ } from "./tools";
279
279
  export { getShellConfig } from "./utils/shell";
@@ -1,3 +1,4 @@
1
+ import { shutdownSharedGateway } from "@oh-my-pi/pi-coding-agent/ipy/gateway-coordinator";
1
2
  import {
2
3
  checkPythonKernelAvailability,
3
4
  type KernelDisplayOutput,
@@ -8,6 +9,11 @@ import {
8
9
  } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
9
10
  import { OutputSink } from "@oh-my-pi/pi-coding-agent/session/streaming-output";
10
11
  import { logger } from "@oh-my-pi/pi-utils";
12
+
13
+ const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
14
+ const MAX_KERNEL_SESSIONS = 4;
15
+ const CLEANUP_INTERVAL_MS = 30 * 1000; // 30 seconds
16
+
11
17
  export type PythonKernelMode = "session" | "per-call";
12
18
 
13
19
  export interface PythonExecutorOptions {
@@ -77,8 +83,58 @@ interface KernelSession {
77
83
 
78
84
  const kernelSessions = new Map<string, KernelSession>();
79
85
  let cachedPreludeDocs: PreludeHelper[] | null = null;
86
+ let cleanupTimer: NodeJS.Timeout | null = null;
87
+
88
+ function startCleanupTimer(): void {
89
+ if (cleanupTimer) return;
90
+ cleanupTimer = setInterval(() => {
91
+ void cleanupIdleSessions();
92
+ }, CLEANUP_INTERVAL_MS);
93
+ cleanupTimer.unref();
94
+ }
95
+
96
+ function stopCleanupTimer(): void {
97
+ if (cleanupTimer) {
98
+ clearInterval(cleanupTimer);
99
+ cleanupTimer = null;
100
+ }
101
+ }
102
+
103
+ async function cleanupIdleSessions(): Promise<void> {
104
+ const now = Date.now();
105
+ const toDispose: KernelSession[] = [];
106
+
107
+ for (const session of kernelSessions.values()) {
108
+ if (session.dead || now - session.lastUsedAt > IDLE_TIMEOUT_MS) {
109
+ toDispose.push(session);
110
+ }
111
+ }
112
+
113
+ if (toDispose.length > 0) {
114
+ logger.debug("Cleaning up idle kernel sessions", { count: toDispose.length });
115
+ await Promise.allSettled(toDispose.map((session) => disposeKernelSession(session)));
116
+ }
117
+
118
+ if (kernelSessions.size === 0) {
119
+ stopCleanupTimer();
120
+ }
121
+ }
122
+
123
+ async function evictOldestSession(): Promise<void> {
124
+ let oldest: KernelSession | null = null;
125
+ for (const session of kernelSessions.values()) {
126
+ if (!oldest || session.lastUsedAt < oldest.lastUsedAt) {
127
+ oldest = session;
128
+ }
129
+ }
130
+ if (oldest) {
131
+ logger.debug("Evicting oldest kernel session", { id: oldest.id });
132
+ await disposeKernelSession(oldest);
133
+ }
134
+ }
80
135
 
81
136
  export async function disposeAllKernelSessions(): Promise<void> {
137
+ stopCleanupTimer();
82
138
  const sessions = Array.from(kernelSessions.values());
83
139
  await Promise.allSettled(sessions.map((session) => disposeKernelSession(session)));
84
140
  }
@@ -132,12 +188,36 @@ export function resetPreludeDocsCache(): void {
132
188
  cachedPreludeDocs = null;
133
189
  }
134
190
 
191
+ function isResourceExhaustionError(error: unknown): boolean {
192
+ const message = error instanceof Error ? error.message : String(error);
193
+ return (
194
+ message.includes("Too many open files") ||
195
+ message.includes("EMFILE") ||
196
+ message.includes("ENFILE") ||
197
+ message.includes("resource temporarily unavailable")
198
+ );
199
+ }
200
+
201
+ async function recoverFromResourceExhaustion(): Promise<void> {
202
+ logger.warn("Resource exhaustion detected, recovering by restarting shared gateway");
203
+ stopCleanupTimer();
204
+ const sessions = Array.from(kernelSessions.values());
205
+ for (const session of sessions) {
206
+ if (session.heartbeatTimer) {
207
+ clearInterval(session.heartbeatTimer);
208
+ }
209
+ kernelSessions.delete(session.id);
210
+ }
211
+ await shutdownSharedGateway();
212
+ }
213
+
135
214
  async function createKernelSession(
136
215
  sessionId: string,
137
216
  cwd: string,
138
217
  useSharedGateway?: boolean,
139
218
  sessionFile?: string,
140
219
  artifactsDir?: string,
220
+ isRetry?: boolean,
141
221
  ): Promise<KernelSession> {
142
222
  const env: Record<string, string> | undefined =
143
223
  sessionFile || artifactsDir
@@ -146,7 +226,18 @@ async function createKernelSession(
146
226
  ...(artifactsDir ? { ARTIFACTS: artifactsDir } : {}),
147
227
  }
148
228
  : undefined;
149
- const kernel = await PythonKernel.start({ cwd, useSharedGateway, env });
229
+
230
+ let kernel: PythonKernel;
231
+ try {
232
+ kernel = await PythonKernel.start({ cwd, useSharedGateway, env });
233
+ } catch (err) {
234
+ if (!isRetry && isResourceExhaustionError(err)) {
235
+ await recoverFromResourceExhaustion();
236
+ return createKernelSession(sessionId, cwd, useSharedGateway, sessionFile, artifactsDir, true);
237
+ }
238
+ throw err;
239
+ }
240
+
150
241
  const session: KernelSession = {
151
242
  id: sessionId,
152
243
  kernel,
@@ -218,8 +309,13 @@ async function withKernelSession<T>(
218
309
  ): Promise<T> {
219
310
  let session = kernelSessions.get(sessionId);
220
311
  if (!session) {
312
+ // Evict oldest session if at capacity
313
+ if (kernelSessions.size >= MAX_KERNEL_SESSIONS) {
314
+ await evictOldestSession();
315
+ }
221
316
  session = await createKernelSession(sessionId, cwd, useSharedGateway, sessionFile, artifactsDir);
222
317
  kernelSessions.set(sessionId, session);
318
+ startCleanupTimer();
223
319
  }
224
320
 
225
321
  const run = async (): Promise<T> => {
package/src/lsp/index.ts CHANGED
@@ -5,7 +5,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb
5
5
  import { renderPromptTemplate } from "@oh-my-pi/pi-coding-agent/config/prompt-templates";
6
6
  import { type Theme, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
7
7
  import lspDescription from "@oh-my-pi/pi-coding-agent/prompts/tools/lsp.md" with { type: "text" };
8
- import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools/index";
8
+ import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
9
9
  import { resolveToCwd } from "@oh-my-pi/pi-coding-agent/tools/path-utils";
10
10
  import { throwIfAborted } from "@oh-my-pi/pi-coding-agent/tools/tool-errors";
11
11
  import { logger, once, untilAborted } from "@oh-my-pi/pi-utils";
package/src/lsp/render.ts CHANGED
@@ -16,7 +16,8 @@ import {
16
16
  TRUNCATE_LENGTHS,
17
17
  truncate,
18
18
  } from "@oh-my-pi/pi-coding-agent/tools/render-utils";
19
- import { Text } from "@oh-my-pi/pi-tui";
19
+ import { renderOutputBlock, renderStatusLine } from "@oh-my-pi/pi-coding-agent/tui";
20
+ import { type Component, Text } from "@oh-my-pi/pi-tui";
20
21
  import { highlight, supportsLanguage } from "cli-highlight";
21
22
  import type { LspParams, LspToolDetails } from "./types";
22
23
 
@@ -30,16 +31,13 @@ import type { LspParams, LspToolDetails } from "./types";
30
31
  */
31
32
  export function renderCall(args: unknown, theme: Theme): Text {
32
33
  const p = args as LspParams & { file?: string; files?: string[] };
33
-
34
- let text = theme.fg("toolTitle", theme.bold("LSP"));
35
- text += ` ${theme.fg("accent", p.action || "?")}`;
36
-
34
+ const meta: string[] = [];
37
35
  if (p.file) {
38
- text += ` ${theme.fg("muted", p.file)}`;
36
+ meta.push(p.file);
39
37
  } else if (p.files?.length) {
40
- text += ` ${theme.fg("muted", `${p.files.length} file(s)`)}`;
38
+ meta.push(`${p.files.length} file(s)`);
41
39
  }
42
-
40
+ const text = renderStatusLine({ icon: "pending", title: "LSP", description: p.action || "?", meta }, theme);
43
41
  return new Text(text, 0, 0);
44
42
  }
45
43
 
@@ -55,40 +53,74 @@ export function renderResult(
55
53
  result: AgentToolResult<LspToolDetails>,
56
54
  options: RenderResultOptions,
57
55
  theme: Theme,
58
- ): Text {
56
+ args?: LspParams & { file?: string; files?: string[] },
57
+ ): Component {
59
58
  const content = result.content?.[0];
60
59
  if (!content || content.type !== "text" || !("text" in content) || !content.text) {
61
- return new Text(theme.fg("error", "No result"), 0, 0);
60
+ const header = renderStatusLine({ icon: "warning", title: "LSP", description: "No result" }, theme);
61
+ return new Text([header, theme.fg("dim", "No result")].join("\n"), 0, 0);
62
62
  }
63
63
 
64
64
  const text = content.text;
65
- const lines = text.split("\n").filter((l) => l.trim());
65
+ const lines = text.split("\n");
66
66
  const expanded = options.expanded;
67
67
 
68
- // Detect result type and render accordingly
68
+ let label = "Result";
69
+ let state: "success" | "warning" | "error" = "success";
70
+ let bodyLines: string[] = [];
71
+
69
72
  const codeBlockMatch = text.match(/```(\w*)\n([\s\S]*?)```/);
70
73
  if (codeBlockMatch) {
71
- return renderHover(codeBlockMatch, text, lines, expanded, theme);
72
- }
73
-
74
- const errorMatch = text.match(/(\d+)\s+error\(s\)/);
75
- const warningMatch = text.match(/(\d+)\s+warning\(s\)/);
76
- if (errorMatch || warningMatch || text.includes(theme.status.error)) {
77
- return renderDiagnostics(errorMatch, warningMatch, lines, expanded, theme);
78
- }
79
-
80
- const refMatch = text.match(/(\d+)\s+reference\(s\)/);
81
- if (refMatch) {
82
- return renderReferences(refMatch, lines, expanded, theme);
74
+ label = "Hover";
75
+ bodyLines = renderHover(codeBlockMatch, text, lines, expanded, theme);
76
+ } else {
77
+ const errorMatch = text.match(/(\d+)\s+error\(s\)/);
78
+ const warningMatch = text.match(/(\d+)\s+warning\(s\)/);
79
+ if (errorMatch || warningMatch || text.includes(theme.status.error)) {
80
+ label = "Diagnostics";
81
+ const errorCount = errorMatch ? Number.parseInt(errorMatch[1], 10) : 0;
82
+ const warnCount = warningMatch ? Number.parseInt(warningMatch[1], 10) : 0;
83
+ state = errorCount > 0 ? "error" : warnCount > 0 ? "warning" : "success";
84
+ bodyLines = renderDiagnostics(errorMatch, warningMatch, lines, expanded, theme);
85
+ } else {
86
+ const refMatch = text.match(/(\d+)\s+reference\(s\)/);
87
+ if (refMatch) {
88
+ label = "References";
89
+ bodyLines = renderReferences(refMatch, lines, expanded, theme);
90
+ } else {
91
+ const symbolsMatch = text.match(/Symbols in (.+):/);
92
+ if (symbolsMatch) {
93
+ label = "Symbols";
94
+ bodyLines = renderSymbols(symbolsMatch, lines, expanded, theme);
95
+ } else {
96
+ label = "Response";
97
+ bodyLines = renderGeneric(text, lines, expanded, theme);
98
+ }
99
+ }
100
+ }
83
101
  }
84
102
 
85
- const symbolsMatch = text.match(/Symbols in (.+):/);
86
- if (symbolsMatch) {
87
- return renderSymbols(symbolsMatch, lines, expanded, theme);
103
+ const meta: string[] = [];
104
+ if (args?.action) meta.push(args.action);
105
+ if (args?.file) {
106
+ meta.push(args.file);
107
+ } else if (args?.files?.length) {
108
+ meta.push(`${args.files.length} file(s)`);
88
109
  }
89
-
90
- // Default fallback rendering
91
- return renderGeneric(text, lines, expanded, theme);
110
+ const header = renderStatusLine({ icon: state, title: "LSP", description: label, meta }, theme);
111
+ return {
112
+ render: (width: number) =>
113
+ renderOutputBlock(
114
+ {
115
+ header,
116
+ state,
117
+ sections: [{ label: theme.fg("toolTitle", label), lines: bodyLines }],
118
+ width,
119
+ },
120
+ theme,
121
+ ),
122
+ invalidate: () => {},
123
+ };
92
124
  }
93
125
 
94
126
  // =============================================================================
@@ -104,9 +136,11 @@ function renderHover(
104
136
  _lines: string[],
105
137
  expanded: boolean,
106
138
  theme: Theme,
107
- ): Text {
139
+ ): string[] {
108
140
  const lang = codeBlockMatch[1] || "";
109
141
  const code = codeBlockMatch[2].trim();
142
+ const codeStart = codeBlockMatch.index ?? 0;
143
+ const beforeCode = fullText.slice(0, codeStart).trimEnd();
110
144
  const afterCode = fullText.slice(fullText.indexOf("```", 3) + 3).trim();
111
145
 
112
146
  const codeLines = highlightCode(code, lang, theme);
@@ -119,6 +153,11 @@ function renderHover(
119
153
  const top = `${theme.boxSharp.topLeft}${h.repeat(3)}`;
120
154
  const bottom = `${theme.boxSharp.bottomLeft}${h.repeat(3)}`;
121
155
  let output = `${icon}${langLabel}`;
156
+ if (beforeCode) {
157
+ for (const line of beforeCode.split("\n")) {
158
+ output += `\n ${theme.fg("muted", line)}`;
159
+ }
160
+ }
122
161
  output += `\n ${theme.fg("mdCodeBlockBorder", top)}`;
123
162
  for (const line of codeLines) {
124
163
  output += `\n ${theme.fg("mdCodeBlockBorder", v)} ${line}`;
@@ -127,15 +166,19 @@ function renderHover(
127
166
  if (afterCode) {
128
167
  output += `\n ${theme.fg("muted", afterCode)}`;
129
168
  }
130
- return new Text(output, 0, 0);
169
+ return output.split("\n");
131
170
  }
132
171
 
133
172
  // Collapsed view
134
173
  const firstCodeLine = codeLines[0] || "";
135
- const hasMore = codeLines.length > 1 || Boolean(afterCode);
174
+ const hasMore = codeLines.length > 1 || Boolean(afterCode) || Boolean(beforeCode);
136
175
  const expandHint = formatExpandHint(theme, expanded, hasMore);
137
176
 
138
177
  let output = `${icon}${langLabel}${expandHint}`;
178
+ if (beforeCode) {
179
+ const preview = truncate(beforeCode, TRUNCATE_LENGTHS.TITLE, theme.format.ellipsis);
180
+ output += `\n ${theme.fg("dim", theme.tree.branch)} ${theme.fg("muted", preview)}`;
181
+ }
139
182
  const h = theme.boxSharp.horizontal;
140
183
  const v = theme.boxSharp.vertical;
141
184
  const bottom = `${theme.boxSharp.bottomLeft}${h.repeat(3)}`;
@@ -155,7 +198,7 @@ function renderHover(
155
198
  output += `\n ${theme.fg("mdCodeBlockBorder", bottom)}`;
156
199
  }
157
200
 
158
- return new Text(output, 0, 0);
201
+ return output.split("\n");
159
202
  }
160
203
 
161
204
  /**
@@ -206,7 +249,7 @@ function renderDiagnostics(
206
249
  lines: string[],
207
250
  expanded: boolean,
208
251
  theme: Theme,
209
- ): Text {
252
+ ): string[] {
210
253
  const errorCount = errorMatch ? Number.parseInt(errorMatch[1], 10) : 0;
211
254
  const warnCount = warningMatch ? Number.parseInt(warningMatch[1], 10) : 0;
212
255
 
@@ -253,7 +296,7 @@ function renderDiagnostics(
253
296
  )}`;
254
297
  }
255
298
  }
256
- return new Text(output, 0, 0);
299
+ return output.split("\n");
257
300
  }
258
301
 
259
302
  // Collapsed view
@@ -285,7 +328,7 @@ function renderDiagnostics(
285
328
  )}`;
286
329
  }
287
330
 
288
- return new Text(output, 0, 0);
331
+ return output.split("\n");
289
332
  }
290
333
 
291
334
  // =============================================================================
@@ -295,7 +338,7 @@ function renderDiagnostics(
295
338
  /**
296
339
  * Render references grouped by file.
297
340
  */
298
- function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded: boolean, theme: Theme): Text {
341
+ function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded: boolean, theme: Theme): string[] {
299
342
  const refCount = Number.parseInt(refMatch[1], 10);
300
343
  const icon =
301
344
  refCount > 0 ? theme.styledSymbol("status.success", "success") : theme.styledSymbol("status.warning", "warning");
@@ -369,10 +412,10 @@ function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded:
369
412
  };
370
413
 
371
414
  if (expanded) {
372
- return new Text(renderGrouped(files.length, 3, false), 0, 0);
415
+ return renderGrouped(files.length, 3, false).split("\n");
373
416
  }
374
417
 
375
- return new Text(renderGrouped(3, 1, true), 0, 0);
418
+ return renderGrouped(3, 1, true).split("\n");
376
419
  }
377
420
 
378
421
  // =============================================================================
@@ -382,7 +425,7 @@ function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded:
382
425
  /**
383
426
  * Render document symbols in a hierarchical tree.
384
427
  */
385
- function renderSymbols(symbolsMatch: RegExpMatchArray, lines: string[], expanded: boolean, theme: Theme): Text {
428
+ function renderSymbols(symbolsMatch: RegExpMatchArray, lines: string[], expanded: boolean, theme: Theme): string[] {
386
429
  const fileName = symbolsMatch[1];
387
430
  const icon = theme.styledSymbol("status.info", "accent");
388
431
 
@@ -450,7 +493,7 @@ function renderSymbols(symbolsMatch: RegExpMatchArray, lines: string[], expanded
450
493
  output += `\n${prefix}${theme.fg("dim", branch)} ${theme.fg("accent", sym.icon)} ${theme.fg("accent", sym.name)}`;
451
494
  output += `\n${prefix}${theme.fg("dim", detailPrefix)}${theme.fg("muted", `line ${sym.line}`)}`;
452
495
  }
453
- return new Text(output, 0, 0);
496
+ return output.split("\n");
454
497
  }
455
498
 
456
499
  // Collapsed: show first 3 top-level symbols
@@ -474,7 +517,7 @@ function renderSymbols(symbolsMatch: RegExpMatchArray, lines: string[], expanded
474
517
  )}`;
475
518
  }
476
519
 
477
- return new Text(output, 0, 0);
520
+ return output.split("\n");
478
521
  }
479
522
 
480
523
  // =============================================================================
@@ -484,7 +527,7 @@ function renderSymbols(symbolsMatch: RegExpMatchArray, lines: string[], expanded
484
527
  /**
485
528
  * Generic fallback rendering for unknown result types.
486
529
  */
487
- function renderGeneric(text: string, lines: string[], expanded: boolean, theme: Theme): Text {
530
+ function renderGeneric(text: string, lines: string[], expanded: boolean, theme: Theme): string[] {
488
531
  const hasError = text.includes("Error:") || text.includes(theme.status.error);
489
532
  const hasSuccess = text.includes(theme.status.success) || text.includes("Applied");
490
533
 
@@ -502,7 +545,7 @@ function renderGeneric(text: string, lines: string[], expanded: boolean, theme:
502
545
  const branch = isLast ? theme.tree.last : theme.tree.branch;
503
546
  output += `\n ${theme.fg("dim", branch)} ${lines[i]}`;
504
547
  }
505
- return new Text(output, 0, 0);
548
+ return output.split("\n");
506
549
  }
507
550
 
508
551
  const firstLine = lines[0] || "No output";
@@ -530,7 +573,7 @@ function renderGeneric(text: string, lines: string[], expanded: boolean, theme:
530
573
  }
531
574
  }
532
575
 
533
- return new Text(output, 0, 0);
576
+ return output.split("\n");
534
577
  }
535
578
 
536
579
  // =============================================================================
@@ -574,4 +617,5 @@ function severityToColor(severity: string): "error" | "warning" | "accent" | "di
574
617
  export const lspToolRenderer = {
575
618
  renderCall,
576
619
  renderResult,
620
+ mergeCallAndResult: true,
577
621
  };
package/src/main.ts CHANGED
@@ -21,15 +21,17 @@ import { selectSession } from "./cli/session-picker";
21
21
  import { parseSetupArgs, printSetupHelp, runSetupCommand } from "./cli/setup-cli";
22
22
  import { parseStatsArgs, printStatsHelp, runStatsCommand } from "./cli/stats-cli";
23
23
  import { parseUpdateArgs, printUpdateHelp, runUpdateCommand } from "./cli/update-cli";
24
+ import { runCommitCommand } from "./commit";
25
+ import { parseCommitArgs, printCommitHelp } from "./commit/cli";
24
26
  import { findConfigFile, getModelsPath, VERSION } from "./config";
25
27
  import type { ModelRegistry } from "./config/model-registry";
26
28
  import { parseModelPattern, parseModelString, resolveModelScope, type ScopedModel } from "./config/model-resolver";
27
29
  import { SettingsManager } from "./config/settings-manager";
28
30
  import { initializeWithSettings } from "./discovery";
29
- import { exportFromFile } from "./export/html/index";
31
+ import { exportFromFile } from "./export/html";
30
32
  import type { ExtensionUIContext } from "./extensibility/extensions/types";
31
33
  import { runMigrations, showDeprecationWarnings } from "./migrations";
32
- import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index";
34
+ import { InteractiveMode, runPrintMode, runRpcMode } from "./modes";
33
35
  import { type CreateAgentSessionOptions, createAgentSession, discoverAuthStorage, discoverModels } from "./sdk";
34
36
  import type { AgentSession } from "./session/agent-session";
35
37
  import { type SessionInfo, SessionManager } from "./session/session-manager";
@@ -85,7 +87,7 @@ async function runInteractiveMode(
85
87
  initialMessages: string[],
86
88
  setExtensionUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void,
87
89
  lspServers: Array<{ name: string; status: "ready" | "error"; fileTypes: string[] }> | undefined,
88
- mcpManager: import("./mcp/index").MCPManager | undefined,
90
+ mcpManager: import("./mcp").MCPManager | undefined,
89
91
  initialMessage?: string,
90
92
  initialImages?: ImageContent[],
91
93
  ): Promise<void> {
@@ -532,6 +534,17 @@ export async function main(args: string[]) {
532
534
  return;
533
535
  }
534
536
 
537
+ // Handle commit subcommand
538
+ const commitCmd = parseCommitArgs(args);
539
+ if (commitCmd) {
540
+ if (args.includes("--help") || args.includes("-h")) {
541
+ printCommitHelp();
542
+ return;
543
+ }
544
+ await runCommitCommand(commitCmd);
545
+ process.exit(0);
546
+ }
547
+
535
548
  const parsed = parseArgs(args);
536
549
  time("parseArgs");
537
550
  await maybeAutoChdir(parsed);
package/src/mcp/loader.ts CHANGED
@@ -36,10 +36,10 @@ export interface MCPToolsLoadOptions {
36
36
  cacheStorage?: AgentStorage | null;
37
37
  }
38
38
 
39
- function resolveToolCache(storage: AgentStorage | null | undefined): MCPToolCache | null {
39
+ async function resolveToolCache(storage: AgentStorage | null | undefined): Promise<MCPToolCache | null> {
40
40
  if (storage === null) return null;
41
41
  try {
42
- const resolved = storage ?? AgentStorage.open();
42
+ const resolved = storage ?? (await AgentStorage.open());
43
43
  return new MCPToolCache(resolved);
44
44
  } catch (error) {
45
45
  logger.warn("MCP tool cache unavailable", { error: String(error) });
@@ -55,7 +55,7 @@ function resolveToolCache(storage: AgentStorage | null | undefined): MCPToolCach
55
55
  * @returns MCP tools in LoadedCustomTool format for integration
56
56
  */
57
57
  export async function discoverAndLoadMCPTools(cwd: string, options?: MCPToolsLoadOptions): Promise<MCPToolsLoadResult> {
58
- const toolCache = resolveToolCache(options?.cacheStorage);
58
+ const toolCache = await resolveToolCache(options?.cacheStorage);
59
59
  const manager = new MCPManager(cwd, toolCache);
60
60
 
61
61
  let result: MCPLoadResult;
package/src/migrations.ts CHANGED
@@ -15,11 +15,11 @@ import { getAgentDbPath, getAgentDir, getBinDir } from "./config";
15
15
  *
16
16
  * @returns Array of provider names that were migrated
17
17
  */
18
- export function migrateAuthToAgentDb(): string[] {
18
+ export async function migrateAuthToAgentDb(): Promise<string[]> {
19
19
  const agentDir = getAgentDir();
20
20
  const oauthPath = join(agentDir, "oauth.json");
21
21
  const settingsPath = join(agentDir, "settings.json");
22
- const storage = AgentStorage.open(getAgentDbPath(agentDir));
22
+ const storage = await AgentStorage.open(getAgentDbPath(agentDir));
23
23
 
24
24
  const migrated: Record<string, AuthCredential[]> = {};
25
25
  const providers: string[] = [];
@@ -179,7 +179,7 @@ export async function runMigrations(_cwd: string): Promise<{
179
179
  deprecationWarnings: string[];
180
180
  }> {
181
181
  // Then: run data migrations
182
- const migratedAuthProviders = migrateAuthToAgentDb();
182
+ const migratedAuthProviders = await migrateAuthToAgentDb();
183
183
  migrateSessionsFromAgentRoot();
184
184
  migrateToolsToBin();
185
185
 
@@ -1,6 +1,7 @@
1
1
  import type { AssistantMessage } from "@oh-my-pi/pi-ai";
2
+ import { hasPendingMermaid, prerenderMermaid } from "@oh-my-pi/pi-coding-agent/modes/theme/mermaid-cache";
2
3
  import { getMarkdownTheme, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
3
- import { Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
4
+ import { Container, getCapabilities, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
4
5
 
5
6
  /**
6
7
  * Component that renders a complete assistant message
@@ -9,6 +10,7 @@ export class AssistantMessageComponent extends Container {
9
10
  private contentContainer: Container;
10
11
  private hideThinkingBlock: boolean;
11
12
  private lastMessage?: AssistantMessage;
13
+ private prerenderInFlight = false;
12
14
 
13
15
  constructor(message?: AssistantMessage, hideThinkingBlock = false) {
14
16
  super();
@@ -35,12 +37,38 @@ export class AssistantMessageComponent extends Container {
35
37
  this.hideThinkingBlock = hide;
36
38
  }
37
39
 
40
+ private triggerMermaidPrerender(message: AssistantMessage): void {
41
+ const caps = getCapabilities();
42
+ if (!caps.images || this.prerenderInFlight) return;
43
+
44
+ // Check if any text content has pending mermaid blocks
45
+ const hasPending = message.content.some((c) => c.type === "text" && c.text.trim() && hasPendingMermaid(c.text));
46
+ if (!hasPending) return;
47
+
48
+ this.prerenderInFlight = true;
49
+
50
+ // Fire off background prerender
51
+ (async () => {
52
+ for (const content of message.content) {
53
+ if (content.type === "text" && content.text.trim() && hasPendingMermaid(content.text)) {
54
+ await prerenderMermaid(content.text);
55
+ }
56
+ }
57
+ this.prerenderInFlight = false;
58
+ // Invalidate to re-render with cached images
59
+ this.invalidate();
60
+ })();
61
+ }
62
+
38
63
  updateContent(message: AssistantMessage): void {
39
64
  this.lastMessage = message;
40
65
 
41
66
  // Clear content container
42
67
  this.contentContainer.clear();
43
68
 
69
+ // Trigger background mermaid pre-rendering if needed
70
+ this.triggerMermaidPrerender(message);
71
+
44
72
  const hasVisibleContent = message.content.some(
45
73
  (c) => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()),
46
74
  );