@oh-my-pi/pi-coding-agent 3.20.1 → 3.24.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 (123) hide show
  1. package/CHANGELOG.md +107 -8
  2. package/docs/custom-tools.md +3 -3
  3. package/docs/extensions.md +226 -220
  4. package/docs/hooks.md +2 -2
  5. package/docs/sdk.md +50 -53
  6. package/examples/custom-tools/README.md +2 -17
  7. package/examples/extensions/README.md +76 -74
  8. package/examples/extensions/todo.ts +2 -5
  9. package/examples/hooks/custom-compaction.ts +2 -4
  10. package/examples/hooks/handoff.ts +1 -1
  11. package/examples/hooks/qna.ts +1 -1
  12. package/examples/sdk/02-custom-model.ts +1 -1
  13. package/examples/sdk/README.md +7 -11
  14. package/package.json +6 -6
  15. package/src/cli/args.ts +9 -6
  16. package/src/cli/file-processor.ts +1 -1
  17. package/src/cli/list-models.ts +1 -1
  18. package/src/core/agent-session.ts +16 -5
  19. package/src/core/auth-storage.ts +1 -1
  20. package/src/core/compaction/branch-summarization.ts +2 -2
  21. package/src/core/compaction/compaction.ts +2 -2
  22. package/src/core/compaction/utils.ts +1 -1
  23. package/src/core/custom-tools/types.ts +1 -1
  24. package/src/core/custom-tools/wrapper.ts +0 -1
  25. package/src/core/extensions/index.ts +1 -6
  26. package/src/core/extensions/runner.ts +1 -1
  27. package/src/core/extensions/types.ts +1 -1
  28. package/src/core/extensions/wrapper.ts +1 -8
  29. package/src/core/file-mentions.ts +5 -8
  30. package/src/core/hooks/runner.ts +2 -2
  31. package/src/core/hooks/types.ts +1 -1
  32. package/src/core/messages.ts +1 -1
  33. package/src/core/model-registry.ts +1 -1
  34. package/src/core/model-resolver.ts +1 -1
  35. package/src/core/sdk.ts +64 -105
  36. package/src/core/session-manager.ts +18 -22
  37. package/src/core/settings-manager.ts +66 -1
  38. package/src/core/slash-commands.ts +12 -5
  39. package/src/core/system-prompt.ts +49 -36
  40. package/src/core/title-generator.ts +2 -2
  41. package/src/core/tools/ask.ts +98 -4
  42. package/src/core/tools/bash-interceptor.ts +11 -4
  43. package/src/core/tools/bash.ts +121 -5
  44. package/src/core/tools/context.ts +7 -0
  45. package/src/core/tools/edit-diff.ts +73 -24
  46. package/src/core/tools/edit.ts +221 -34
  47. package/src/core/tools/exa/render.ts +4 -16
  48. package/src/core/tools/find.ts +149 -5
  49. package/src/core/tools/gemini-image.ts +279 -56
  50. package/src/core/tools/git.ts +17 -3
  51. package/src/core/tools/grep.ts +185 -5
  52. package/src/core/tools/index.test.ts +180 -0
  53. package/src/core/tools/index.ts +96 -242
  54. package/src/core/tools/ls.ts +133 -5
  55. package/src/core/tools/lsp/index.ts +32 -29
  56. package/src/core/tools/lsp/render.ts +21 -22
  57. package/src/core/tools/notebook.ts +112 -4
  58. package/src/core/tools/output.ts +175 -15
  59. package/src/core/tools/read.ts +127 -25
  60. package/src/core/tools/render-utils.ts +241 -0
  61. package/src/core/tools/renderers.ts +40 -828
  62. package/src/core/tools/review.ts +26 -25
  63. package/src/core/tools/rulebook.ts +11 -3
  64. package/src/core/tools/task/agents.ts +28 -7
  65. package/src/core/tools/task/discovery.ts +0 -6
  66. package/src/core/tools/task/executor.ts +264 -254
  67. package/src/core/tools/task/index.ts +48 -208
  68. package/src/core/tools/task/render.ts +26 -11
  69. package/src/core/tools/task/types.ts +7 -12
  70. package/src/core/tools/task/worker-protocol.ts +17 -0
  71. package/src/core/tools/task/worker.ts +238 -0
  72. package/src/core/tools/truncate.ts +27 -1
  73. package/src/core/tools/web-fetch.ts +25 -49
  74. package/src/core/tools/web-search/index.ts +132 -46
  75. package/src/core/tools/web-search/providers/anthropic.ts +7 -2
  76. package/src/core/tools/web-search/providers/exa.ts +2 -1
  77. package/src/core/tools/web-search/providers/perplexity.ts +6 -1
  78. package/src/core/tools/web-search/render.ts +6 -4
  79. package/src/core/tools/web-search/types.ts +13 -0
  80. package/src/core/tools/write.ts +96 -14
  81. package/src/core/voice.ts +1 -1
  82. package/src/discovery/helpers.test.ts +1 -1
  83. package/src/index.ts +5 -16
  84. package/src/main.ts +5 -5
  85. package/src/modes/interactive/components/assistant-message.ts +1 -1
  86. package/src/modes/interactive/components/custom-message.ts +1 -1
  87. package/src/modes/interactive/components/extensions/inspector-panel.ts +25 -22
  88. package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
  89. package/src/modes/interactive/components/footer.ts +1 -1
  90. package/src/modes/interactive/components/hook-message.ts +1 -1
  91. package/src/modes/interactive/components/model-selector.ts +1 -1
  92. package/src/modes/interactive/components/oauth-selector.ts +1 -1
  93. package/src/modes/interactive/components/settings-defs.ts +49 -0
  94. package/src/modes/interactive/components/status-line.ts +1 -1
  95. package/src/modes/interactive/components/tool-execution.ts +93 -538
  96. package/src/modes/interactive/interactive-mode.ts +19 -7
  97. package/src/modes/interactive/theme/theme.ts +4 -4
  98. package/src/modes/print-mode.ts +1 -1
  99. package/src/modes/rpc/rpc-client.ts +1 -1
  100. package/src/modes/rpc/rpc-types.ts +1 -1
  101. package/src/prompts/system-prompt.md +4 -0
  102. package/src/prompts/task.md +0 -7
  103. package/src/prompts/tools/gemini-image.md +5 -1
  104. package/src/prompts/tools/output.md +6 -2
  105. package/src/prompts/tools/task.md +68 -0
  106. package/src/prompts/tools/web-fetch.md +1 -0
  107. package/src/prompts/tools/web-search.md +2 -0
  108. package/src/utils/image-convert.ts +8 -2
  109. package/src/utils/image-magick.ts +247 -0
  110. package/src/utils/image-resize.ts +53 -13
  111. package/examples/custom-tools/question/index.ts +0 -84
  112. package/examples/custom-tools/subagent/README.md +0 -172
  113. package/examples/custom-tools/subagent/agents/planner.md +0 -37
  114. package/examples/custom-tools/subagent/agents/scout.md +0 -50
  115. package/examples/custom-tools/subagent/agents/worker.md +0 -24
  116. package/examples/custom-tools/subagent/agents.ts +0 -156
  117. package/examples/custom-tools/subagent/commands/implement-and-review.md +0 -10
  118. package/examples/custom-tools/subagent/commands/implement.md +0 -10
  119. package/examples/custom-tools/subagent/commands/scout-and-plan.md +0 -9
  120. package/examples/custom-tools/subagent/index.ts +0 -1002
  121. package/examples/sdk/05-tools.ts +0 -94
  122. package/examples/sdk/12-full-control.ts +0 -95
  123. package/src/prompts/browser.md +0 -71
@@ -13,11 +13,11 @@ import {
13
13
  type WriteStream,
14
14
  } from "node:fs";
15
15
  import { basename, join, resolve } from "node:path";
16
+ import type { ImageContent, Message, TextContent, Usage } from "@mariozechner/pi-ai";
16
17
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
17
- import type { ImageContent, Message, TextContent, Usage } from "@oh-my-pi/pi-ai";
18
18
  import { nanoid } from "nanoid";
19
- import sharp from "sharp";
20
19
  import { getAgentDir as getDefaultAgentDir } from "../config";
20
+ import { resizeImage } from "../utils/image-resize";
21
21
  import {
22
22
  type BashExecutionMessage,
23
23
  type CustomMessage,
@@ -644,28 +644,17 @@ function isImageBlock(value: unknown): value is { type: "image"; data: string; m
644
644
 
645
645
  async function compressImageForPersistence(image: ImageContent): Promise<ImageContent> {
646
646
  try {
647
- const buffer = Buffer.from(image.data, "base64");
648
- const pipeline = sharp(buffer, { failOnError: false });
649
- const metadata = await pipeline.metadata();
650
- const width = metadata.width ?? 0;
651
- const height = metadata.height ?? 0;
652
- const hasDims = width > 0 && height > 0;
653
- const targetWidth = hasDims && width >= height ? 512 : undefined;
654
- const targetHeight = hasDims && height > width ? 512 : undefined;
655
- const resized = await pipeline
656
- .resize({
657
- width: hasDims ? targetWidth : 512,
658
- height: hasDims ? targetHeight : 512,
659
- fit: "inside",
660
- withoutEnlargement: true,
661
- })
662
- .jpeg({ quality: 70 })
663
- .toBuffer();
664
- const base64 = resized.toString("base64");
665
- if (base64.length > MAX_PERSIST_CHARS) {
647
+ const maxBytes = Math.floor((MAX_PERSIST_CHARS * 3) / 4);
648
+ const resized = await resizeImage(image, {
649
+ maxWidth: 512,
650
+ maxHeight: 512,
651
+ maxBytes,
652
+ jpegQuality: 70,
653
+ });
654
+ if (resized.data.length > MAX_PERSIST_CHARS) {
666
655
  return { type: "image", data: PLACEHOLDER_IMAGE_DATA, mimeType: "image/jpeg" };
667
656
  }
668
- return { type: "image", data: base64, mimeType: "image/jpeg" };
657
+ return { type: "image", data: resized.data, mimeType: resized.mimeType };
669
658
  } catch {
670
659
  return { type: "image", data: PLACEHOLDER_IMAGE_DATA, mimeType: "image/jpeg" };
671
660
  }
@@ -709,6 +698,13 @@ async function truncateForPersistence<T>(obj: T, key?: string): Promise<T> {
709
698
  let changed = false;
710
699
  const result: Record<string, unknown> = {};
711
700
  for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
701
+ // Strip transient/redundant properties that shouldn't be persisted
702
+ // - partialJson: streaming accumulator for tool call JSON parsing
703
+ // - jsonlEvents: raw subprocess streaming events (already saved to artifact files)
704
+ if (k === "partialJson" || k === "jsonlEvents") {
705
+ changed = true;
706
+ continue;
707
+ }
712
708
  const newV = await truncateForPersistence(v, k);
713
709
  result[k] = newV;
714
710
  if (newV !== v) changed = true;
@@ -12,6 +12,7 @@ export interface CompactionSettings {
12
12
  }
13
13
 
14
14
  export interface BranchSummarySettings {
15
+ enabled?: boolean; // default: false (prompt user to summarize when leaving branch)
15
16
  reserveTokens?: number; // default: 16384 (tokens reserved for prompt + LLM response)
16
17
  }
17
18
 
@@ -61,10 +62,22 @@ export interface ExaSettings {
61
62
  enableWebsets?: boolean; // default: false
62
63
  }
63
64
 
65
+ export type WebSearchProviderOption = "auto" | "exa" | "perplexity" | "anthropic";
66
+ export type ImageProviderOption = "auto" | "gemini" | "openrouter";
67
+
68
+ export interface ProviderSettings {
69
+ webSearch?: WebSearchProviderOption; // default: "auto" (exa > perplexity > anthropic)
70
+ image?: ImageProviderOption; // default: "auto" (openrouter > gemini)
71
+ }
72
+
64
73
  export interface BashInterceptorSettings {
65
74
  enabled?: boolean; // default: false (blocks shell commands that have dedicated tools)
66
75
  }
67
76
 
77
+ export interface GitSettings {
78
+ enabled?: boolean; // default: false (structured git tool; use bash for git commands when disabled)
79
+ }
80
+
68
81
  export interface MCPSettings {
69
82
  enableProjectConfig?: boolean; // default: true (load .mcp.json from project root)
70
83
  }
@@ -166,11 +179,13 @@ export interface Settings {
166
179
  enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag)
167
180
  exa?: ExaSettings;
168
181
  bashInterceptor?: BashInterceptorSettings;
182
+ git?: GitSettings;
169
183
  mcp?: MCPSettings;
170
184
  lsp?: LspSettings;
171
185
  edit?: EditSettings;
172
186
  ttsr?: TtsrSettings;
173
187
  voice?: VoiceSettings;
188
+ providers?: ProviderSettings;
174
189
  disabledProviders?: string[]; // Discovery provider IDs that are disabled
175
190
  disabledExtensions?: string[]; // Individual extension IDs that are disabled (e.g., "skill:commit")
176
191
  statusLine?: StatusLineSettings; // Status line configuration
@@ -432,8 +447,21 @@ export class SettingsManager {
432
447
  };
433
448
  }
434
449
 
435
- getBranchSummarySettings(): { reserveTokens: number } {
450
+ getBranchSummaryEnabled(): boolean {
451
+ return this.settings.branchSummary?.enabled ?? false;
452
+ }
453
+
454
+ setBranchSummaryEnabled(enabled: boolean): void {
455
+ if (!this.globalSettings.branchSummary) {
456
+ this.globalSettings.branchSummary = {};
457
+ }
458
+ this.globalSettings.branchSummary.enabled = enabled;
459
+ this.save();
460
+ }
461
+
462
+ getBranchSummarySettings(): { enabled: boolean; reserveTokens: number } {
436
463
  return {
464
+ enabled: this.getBranchSummaryEnabled(),
437
465
  reserveTokens: this.settings.branchSummary?.reserveTokens ?? 16384,
438
466
  };
439
467
  }
@@ -626,6 +654,31 @@ export class SettingsManager {
626
654
  this.save();
627
655
  }
628
656
 
657
+ // Provider settings
658
+ getWebSearchProvider(): WebSearchProviderOption {
659
+ return this.settings.providers?.webSearch ?? "auto";
660
+ }
661
+
662
+ setWebSearchProvider(provider: WebSearchProviderOption): void {
663
+ if (!this.globalSettings.providers) {
664
+ this.globalSettings.providers = {};
665
+ }
666
+ this.globalSettings.providers.webSearch = provider;
667
+ this.save();
668
+ }
669
+
670
+ getImageProvider(): ImageProviderOption {
671
+ return this.settings.providers?.image ?? "auto";
672
+ }
673
+
674
+ setImageProvider(provider: ImageProviderOption): void {
675
+ if (!this.globalSettings.providers) {
676
+ this.globalSettings.providers = {};
677
+ }
678
+ this.globalSettings.providers.image = provider;
679
+ this.save();
680
+ }
681
+
629
682
  getBashInterceptorEnabled(): boolean {
630
683
  return this.settings.bashInterceptor?.enabled ?? false;
631
684
  }
@@ -638,6 +691,18 @@ export class SettingsManager {
638
691
  this.save();
639
692
  }
640
693
 
694
+ getGitToolEnabled(): boolean {
695
+ return this.settings.git?.enabled ?? false;
696
+ }
697
+
698
+ setGitToolEnabled(enabled: boolean): void {
699
+ if (!this.globalSettings.git) {
700
+ this.globalSettings.git = {};
701
+ }
702
+ this.globalSettings.git.enabled = enabled;
703
+ this.save();
704
+ }
705
+
641
706
  getMCPProjectConfigEnabled(): boolean {
642
707
  return this.settings.mcp?.enableProjectConfig ?? true;
643
708
  }
@@ -54,20 +54,27 @@ export function parseCommandArgs(argsString: string): string[] {
54
54
 
55
55
  /**
56
56
  * Substitute argument placeholders in command content
57
- * Supports $1, $2, ... for positional args and $@ for all args
57
+ * Supports $1, $2, ... for positional args, $@ and $ARGUMENTS for all args
58
58
  */
59
59
  export function substituteArgs(content: string, args: string[]): string {
60
60
  let result = content;
61
61
 
62
- // Replace $@ with all args joined
63
- result = result.replace(/\$@/g, args.join(" "));
64
-
65
- // Replace $1, $2, etc. with positional args
62
+ // Replace $1, $2, etc. with positional args FIRST (before wildcards)
63
+ // This prevents wildcard replacement values containing $<digit> patterns from being re-substituted
66
64
  result = result.replace(/\$(\d+)/g, (_, num) => {
67
65
  const index = parseInt(num, 10) - 1;
68
66
  return args[index] ?? "";
69
67
  });
70
68
 
69
+ // Pre-compute all args joined
70
+ const allArgs = args.join(" ");
71
+
72
+ // Replace $ARGUMENTS with all args joined (aligns with Claude, Codex)
73
+ result = result.replace(/\$ARGUMENTS/g, allArgs);
74
+
75
+ // Replace $@ with all args joined
76
+ result = result.replace(/\$@/g, allArgs);
77
+
71
78
  return result;
72
79
  }
73
80
 
@@ -69,11 +69,12 @@ ${commitsText}`;
69
69
  const toolDescriptions: Record<ToolName, string> = {
70
70
  ask: "Ask user for input or clarification",
71
71
  read: "Read file contents",
72
- bash: "Execute bash commands (git, npm, docker, etc.)",
72
+ bash: "Execute bash commands (npm, docker, etc.)",
73
73
  edit: "Make surgical edits to files (find exact text and replace)",
74
74
  write: "Create or overwrite files",
75
75
  grep: "Search file contents for patterns (respects .gitignore)",
76
76
  find: "Find files by glob pattern (respects .gitignore)",
77
+ git: "Structured Git operations with safety guards (status, diff, log, commit, push, pr, etc.)",
77
78
  ls: "List directory contents",
78
79
  lsp: "PREFERRED for semantic code queries: go-to-definition, find-all-references, hover (type info), call hierarchy. Returns precise, deterministic results. Use BEFORE grep for symbol lookups.",
79
80
  notebook: "Edit Jupyter notebook cells",
@@ -99,9 +100,10 @@ function generateAntiBashRules(tools: ToolName[]): string | null {
99
100
  const hasLs = tools.includes("ls");
100
101
  const hasEdit = tools.includes("edit");
101
102
  const hasLsp = tools.includes("lsp");
103
+ const hasGit = tools.includes("git");
102
104
 
103
105
  // Only show rules if we have specialized tools that should be preferred
104
- const hasSpecializedTools = hasRead || hasGrep || hasFind || hasLs || hasEdit;
106
+ const hasSpecializedTools = hasRead || hasGrep || hasFind || hasLs || hasEdit || hasGit;
105
107
  if (!hasSpecializedTools) return null;
106
108
 
107
109
  const lines: string[] = [];
@@ -114,6 +116,7 @@ function generateAntiBashRules(tools: ToolName[]): string | null {
114
116
  if (hasFind) lines.push("- **File finding**: Use `find` instead of find/fd/locate");
115
117
  if (hasLs) lines.push("- **Directory listing**: Use `ls` instead of bash ls");
116
118
  if (hasEdit) lines.push("- **File editing**: Use `edit` instead of sed/awk/perl -pi/echo >/cat <<EOF");
119
+ if (hasGit) lines.push("- **Git operations**: Use `git` tool instead of bash git commands");
117
120
 
118
121
  lines.push("\n### Tool Preference (highest → lowest priority)");
119
122
  const ladder: string[] = [];
@@ -122,7 +125,8 @@ function generateAntiBashRules(tools: ToolName[]): string | null {
122
125
  if (hasFind) ladder.push("find (locate files by pattern)");
123
126
  if (hasRead) ladder.push("read (view file contents)");
124
127
  if (hasEdit) ladder.push("edit (precise text replacement)");
125
- ladder.push("bash (ONLY for git, npm, docker, make, cargo, etc.)");
128
+ if (hasGit) ladder.push("git (structured git operations with safety guards)");
129
+ ladder.push(`bash (ONLY for ${hasGit ? "" : "git, "}npm, docker, make, cargo, etc.)`);
126
130
  lines.push(ladder.map((t, i) => `${i + 1}. ${t}`).join("\n"));
127
131
 
128
132
  // Add LSP guidance if available
@@ -137,6 +141,26 @@ function generateAntiBashRules(tools: ToolName[]): string | null {
137
141
  lines.push("- **Find symbol across codebase** → `lsp workspace_symbols`\n");
138
142
  }
139
143
 
144
+ // Add Git guidance if available
145
+ if (hasGit) {
146
+ lines.push("\n### Git Tool — Preferred for Git Operations");
147
+ lines.push("Use `git` instead of bash git when you need:");
148
+ lines.push(
149
+ "- **Status/diff/log**: `git { operation: 'status' }`, `git { operation: 'diff' }`, `git { operation: 'log' }`",
150
+ );
151
+ lines.push(
152
+ "- **Commit workflow**: `git { operation: 'add', paths: [...] }` then `git { operation: 'commit', message: '...' }`",
153
+ );
154
+ lines.push("- **Branching**: `git { operation: 'branch', action: 'create', name: '...' }`");
155
+ lines.push("- **GitHub PRs**: `git { operation: 'pr', action: 'create', title: '...', body: '...' }`");
156
+ lines.push(
157
+ "- **GitHub Issues**: `git { operation: 'issue', action: 'list' }` or `{ operation: 'issue', number: 123 }`",
158
+ );
159
+ lines.push(
160
+ "The git tool provides typed output, safety guards, and a clean API for all git and GitHub operations.\n",
161
+ );
162
+ }
163
+
140
164
  // Add search-first protocol
141
165
  if (hasGrep || hasFind) {
142
166
  lines.push("\n### Search-First Protocol");
@@ -232,10 +256,10 @@ export function loadSystemPromptFiles(options: LoadContextFilesOptions = {}): st
232
256
  export interface BuildSystemPromptOptions {
233
257
  /** Custom system prompt (replaces default). */
234
258
  customPrompt?: string;
235
- /** Tools to include in prompt. Default: [read, bash, edit, write] */
236
- selectedTools?: ToolName[];
237
- /** Extra tool descriptions to include in prompt (non built-in tools). */
238
- extraToolDescriptions?: Array<{ name: string; description: string }>;
259
+ /** Tools to include in prompt. */
260
+ tools?: Map<string, { description: string; label: string }>;
261
+ /** Tool names to include in prompt. */
262
+ toolNames?: string[];
239
263
  /** Text to append to system prompt. */
240
264
  appendSystemPrompt?: string;
241
265
  /** Skills settings for discovery. */
@@ -247,21 +271,21 @@ export interface BuildSystemPromptOptions {
247
271
  /** Pre-loaded skills (skips discovery if provided). */
248
272
  skills?: Skill[];
249
273
  /** Pre-loaded rulebook rules (rules with descriptions, excluding TTSR and always-apply). */
250
- rulebookRules?: Rule[];
274
+ rules?: Rule[];
251
275
  }
252
276
 
253
277
  /** Build the system prompt with tools, guidelines, and context */
254
278
  export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): string {
255
279
  const {
256
280
  customPrompt,
257
- selectedTools,
258
- extraToolDescriptions = [],
281
+ tools,
259
282
  appendSystemPrompt,
260
283
  skillsSettings,
284
+ toolNames,
261
285
  cwd,
262
286
  contextFiles: providedContextFiles,
263
287
  skills: providedSkills,
264
- rulebookRules,
288
+ rules: rulebookRules,
265
289
  } = options;
266
290
  const resolvedCwd = cwd ?? process.cwd();
267
291
  const resolvedCustomPrompt = resolvePromptInput(customPrompt, "system prompt");
@@ -287,6 +311,9 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
287
311
  // Resolve context files: use provided or discover
288
312
  const contextFiles = providedContextFiles ?? loadProjectContextFiles({ cwd: resolvedCwd });
289
313
 
314
+ // Build tools list based on selected tools
315
+ const toolsList = toolNames?.map((name) => `- ${name}: ${toolDescriptions[name as ToolName]}`).join("\n") ?? "";
316
+
290
317
  // Resolve skills: use provided or discover
291
318
  const skills =
292
319
  providedSkills ??
@@ -311,9 +338,11 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
311
338
  }
312
339
 
313
340
  // Append custom tool descriptions if provided
314
- if (extraToolDescriptions.length > 0) {
315
- prompt += "\n\n# Additional Tools\n\n";
316
- prompt += extraToolDescriptions.map((tool) => `- ${tool.name}: ${tool.description}`).join("\n");
341
+ if (tools && tools.size > 0) {
342
+ prompt += "\n\n# Tools\n\n";
343
+ prompt += Array.from(tools.entries())
344
+ .map(([name, { description }]) => `- ${name}: ${description}`)
345
+ .join("\n");
317
346
  }
318
347
 
319
348
  // Append git context if in a git repo
@@ -323,8 +352,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
323
352
  }
324
353
 
325
354
  // Append skills section (only if read tool is available)
326
- const customPromptHasRead = !selectedTools || selectedTools.includes("read");
327
- if (customPromptHasRead && skills.length > 0) {
355
+ if (tools?.has("read") && skills.length > 0) {
328
356
  prompt += formatSkillsForPrompt(skills);
329
357
  }
330
358
 
@@ -345,25 +373,16 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
345
373
  const docsPath = getDocsPath();
346
374
  const examplesPath = getExamplesPath();
347
375
 
348
- // Build tools list based on selected tools
349
- const tools = selectedTools || (["read", "bash", "edit", "write"] as ToolName[]);
350
- const builtInToolsList = tools.map((t) => `- ${t}: ${toolDescriptions[t]}`).join("\n");
351
- const extraToolsList =
352
- extraToolDescriptions.length > 0
353
- ? extraToolDescriptions.map((tool) => `- ${tool.name}: ${tool.description}`).join("\n")
354
- : "";
355
- const toolsList = [builtInToolsList, extraToolsList].filter(Boolean).join("\n");
356
-
357
376
  // Generate anti-bash rules (returns null if not applicable)
358
- const antiBashSection = generateAntiBashRules(tools);
377
+ const antiBashSection = generateAntiBashRules(Array.from(tools?.keys() ?? []));
359
378
 
360
379
  // Build guidelines based on which tools are actually available
361
380
  const guidelinesList: string[] = [];
362
381
 
363
- const hasBash = tools.includes("bash");
364
- const hasEdit = tools.includes("edit");
365
- const hasWrite = tools.includes("write");
366
- const hasRead = tools.includes("read");
382
+ const hasBash = tools?.has("bash");
383
+ const hasEdit = tools?.has("edit");
384
+ const hasWrite = tools?.has("write");
385
+ const hasRead = tools?.has("read");
367
386
 
368
387
  // Read-only mode notice (no bash, edit, or write)
369
388
  if (!hasBash && !hasEdit && !hasWrite) {
@@ -430,12 +449,6 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
430
449
  }
431
450
  }
432
451
 
433
- // Append custom tool descriptions if provided
434
- if (extraToolDescriptions.length > 0) {
435
- prompt += "\n\n# Additional Tools\n\n";
436
- prompt += extraToolDescriptions.map((tool) => `- ${tool.name}: ${tool.description}`).join("\n");
437
- }
438
-
439
452
  // Append git context if in a git repo
440
453
  const gitContext = loadGitContext(resolvedCwd);
441
454
  if (gitContext) {
@@ -2,8 +2,8 @@
2
2
  * Generate session titles using a smol, fast model.
3
3
  */
4
4
 
5
- import type { Model } from "@oh-my-pi/pi-ai";
6
- import { completeSimple } from "@oh-my-pi/pi-ai";
5
+ import type { Model } from "@mariozechner/pi-ai";
6
+ import { completeSimple } from "@mariozechner/pi-ai";
7
7
  import titleSystemPrompt from "../prompts/title-system.md" with { type: "text" };
8
8
  import { logger } from "./logger";
9
9
  import type { ModelRegistry } from "./model-registry";
@@ -16,9 +16,14 @@
16
16
  */
17
17
 
18
18
  import type { AgentTool, AgentToolContext, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
19
+ import type { Component } from "@oh-my-pi/pi-tui";
20
+ import { Text } from "@oh-my-pi/pi-tui";
19
21
  import { Type } from "@sinclair/typebox";
20
- import { theme } from "../../modes/interactive/theme/theme";
22
+ import { type Theme, theme } from "../../modes/interactive/theme/theme";
21
23
  import askDescription from "../../prompts/tools/ask.md" with { type: "text" };
24
+ import type { RenderResultOptions } from "../custom-tools/types";
25
+ import type { ToolSession } from "./index";
26
+ import { formatErrorMessage, formatMeta } from "./render-utils";
22
27
 
23
28
  // =============================================================================
24
29
  // Types
@@ -63,7 +68,10 @@ function getDoneOptionLabel(): string {
63
68
  // Tool Implementation
64
69
  // =============================================================================
65
70
 
66
- export function createAskTool(_cwd: string): AgentTool<typeof askSchema, AskToolDetails> {
71
+ export function createAskTool(session: ToolSession): null | AgentTool<typeof askSchema, AskToolDetails> {
72
+ if (!session.hasUI) {
73
+ return null;
74
+ }
67
75
  return {
68
76
  name: "ask",
69
77
  label: "Ask",
@@ -189,5 +197,91 @@ export function createAskTool(_cwd: string): AgentTool<typeof askSchema, AskTool
189
197
  };
190
198
  }
191
199
 
192
- /** Default ask tool using process.cwd() - for backwards compatibility (no UI) */
193
- export const askTool = createAskTool(process.cwd());
200
+ /** Default ask tool - returns null when no UI */
201
+ export const askTool = createAskTool({
202
+ cwd: process.cwd(),
203
+ hasUI: false,
204
+ rulebookRules: [],
205
+ getSessionFile: () => null,
206
+ getSessionSpawns: () => "*",
207
+ });
208
+
209
+ // =============================================================================
210
+ // TUI Renderer
211
+ // =============================================================================
212
+
213
+ interface AskRenderArgs {
214
+ question: string;
215
+ options?: Array<{ label: string }>;
216
+ multi?: boolean;
217
+ }
218
+
219
+ export const askToolRenderer = {
220
+ renderCall(args: AskRenderArgs, uiTheme: Theme): Component {
221
+ if (!args.question) {
222
+ return new Text(formatErrorMessage("No question provided", uiTheme), 0, 0);
223
+ }
224
+
225
+ const label = uiTheme.fg("toolTitle", uiTheme.bold("Ask"));
226
+ let text = `${label} ${uiTheme.fg("accent", args.question)}`;
227
+
228
+ const meta: string[] = [];
229
+ if (args.multi) meta.push("multi");
230
+ if (args.options?.length) meta.push(`options:${args.options.length}`);
231
+ text += formatMeta(meta, uiTheme);
232
+
233
+ if (args.options?.length) {
234
+ for (let i = 0; i < args.options.length; i++) {
235
+ const opt = args.options[i];
236
+ const isLast = i === args.options.length - 1;
237
+ const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
238
+ text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${uiTheme.fg("muted", opt.label)}`;
239
+ }
240
+ }
241
+
242
+ return new Text(text, 0, 0);
243
+ },
244
+
245
+ renderResult(
246
+ result: { content: Array<{ type: string; text?: string }>; details?: AskToolDetails },
247
+ _opts: RenderResultOptions,
248
+ uiTheme: Theme,
249
+ ): Component {
250
+ const { details } = result;
251
+ if (!details) {
252
+ const txt = result.content[0];
253
+ return new Text(txt?.type === "text" && txt.text ? txt.text : "", 0, 0);
254
+ }
255
+
256
+ const hasSelection = details.customInput || details.selectedOptions.length > 0;
257
+ const statusIcon = hasSelection
258
+ ? uiTheme.styledSymbol("status.success", "success")
259
+ : uiTheme.styledSymbol("status.warning", "warning");
260
+
261
+ let text = `${statusIcon} ${uiTheme.fg("accent", details.question)}`;
262
+
263
+ if (details.customInput) {
264
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol(
265
+ "status.success",
266
+ "success",
267
+ )} ${uiTheme.fg("toolOutput", details.customInput)}`;
268
+ } else if (details.selectedOptions.length > 0) {
269
+ const selected = details.selectedOptions;
270
+ for (let i = 0; i < selected.length; i++) {
271
+ const isLast = i === selected.length - 1;
272
+ const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
273
+ text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg(
274
+ "success",
275
+ uiTheme.checkbox.checked,
276
+ )} ${uiTheme.fg("toolOutput", selected[i])}`;
277
+ }
278
+ } else {
279
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol(
280
+ "status.warning",
281
+ "warning",
282
+ )} ${uiTheme.fg("warning", "Cancelled")}`;
283
+ }
284
+
285
+ return new Text(text, 0, 0);
286
+ },
287
+ };
@@ -36,6 +36,13 @@ const forbiddenPatterns: Array<{
36
36
  tool: "grep",
37
37
  message: "Use the `grep` tool instead of grep/rg. It respects .gitignore and provides structured output.",
38
38
  },
39
+ // Git operations
40
+ {
41
+ pattern: /^\s*git(\s+|$)/,
42
+ tool: "git",
43
+ message:
44
+ "Use the `git` tool instead of running git in bash. It provides structured output and safety confirmations.",
45
+ },
39
46
  // File finding
40
47
  {
41
48
  pattern: /^\s*(find|fd|locate)\s+.*(-name|-iname|-type|--type|-glob)/,
@@ -73,13 +80,13 @@ const forbiddenPatterns: Array<{
73
80
  * @param availableTools Set of tool names that are available
74
81
  * @returns InterceptionResult indicating if the command should be blocked
75
82
  */
76
- export function checkBashInterception(command: string, availableTools: Set<string>): InterceptionResult {
83
+ export function checkBashInterception(command: string, availableTools: string[]): InterceptionResult {
77
84
  // Normalize command for pattern matching
78
85
  const normalizedCommand = command.trim();
79
86
 
80
87
  for (const { pattern, tool, message } of forbiddenPatterns) {
81
88
  // Only block if the suggested tool is actually available
82
- if (!availableTools.has(tool)) {
89
+ if (!availableTools.includes(tool)) {
83
90
  continue;
84
91
  }
85
92
 
@@ -99,8 +106,8 @@ export function checkBashInterception(command: string, availableTools: Set<strin
99
106
  * Check if a command is a simple directory listing that should use `ls` tool.
100
107
  * Only applies to bare `ls` without complex flags.
101
108
  */
102
- export function checkSimpleLsInterception(command: string, availableTools: Set<string>): InterceptionResult {
103
- if (!availableTools.has("ls")) {
109
+ export function checkSimpleLsInterception(command: string, availableTools: string[]): InterceptionResult {
110
+ if (!availableTools.includes("ls")) {
104
111
  return { block: false };
105
112
  }
106
113