@oh-my-pi/pi-coding-agent 3.20.0 → 3.21.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 (95) hide show
  1. package/CHANGELOG.md +78 -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 +3 -3
  6. package/examples/custom-tools/README.md +2 -2
  7. package/examples/custom-tools/subagent/index.ts +1 -1
  8. package/examples/extensions/README.md +76 -74
  9. package/examples/extensions/todo.ts +2 -5
  10. package/examples/hooks/custom-compaction.ts +1 -1
  11. package/examples/hooks/handoff.ts +1 -1
  12. package/examples/hooks/qna.ts +1 -1
  13. package/examples/sdk/02-custom-model.ts +1 -1
  14. package/examples/sdk/12-full-control.ts +1 -1
  15. package/examples/sdk/README.md +1 -1
  16. package/package.json +5 -5
  17. package/src/cli/file-processor.ts +1 -1
  18. package/src/cli/list-models.ts +1 -1
  19. package/src/core/agent-session.ts +13 -2
  20. package/src/core/auth-storage.ts +1 -1
  21. package/src/core/compaction/branch-summarization.ts +2 -2
  22. package/src/core/compaction/compaction.ts +2 -2
  23. package/src/core/compaction/utils.ts +1 -1
  24. package/src/core/custom-tools/types.ts +1 -1
  25. package/src/core/extensions/runner.ts +1 -1
  26. package/src/core/extensions/types.ts +1 -1
  27. package/src/core/extensions/wrapper.ts +1 -1
  28. package/src/core/hooks/runner.ts +2 -2
  29. package/src/core/hooks/types.ts +1 -1
  30. package/src/core/messages.ts +1 -1
  31. package/src/core/model-registry.ts +1 -1
  32. package/src/core/model-resolver.ts +1 -1
  33. package/src/core/sdk.ts +33 -4
  34. package/src/core/session-manager.ts +11 -22
  35. package/src/core/settings-manager.ts +66 -1
  36. package/src/core/slash-commands.ts +12 -5
  37. package/src/core/system-prompt.ts +27 -3
  38. package/src/core/title-generator.ts +2 -2
  39. package/src/core/tools/ask.ts +88 -1
  40. package/src/core/tools/bash-interceptor.ts +7 -0
  41. package/src/core/tools/bash.ts +106 -0
  42. package/src/core/tools/edit-diff.ts +73 -24
  43. package/src/core/tools/edit.ts +214 -20
  44. package/src/core/tools/find.ts +162 -1
  45. package/src/core/tools/gemini-image.ts +279 -56
  46. package/src/core/tools/git.ts +4 -0
  47. package/src/core/tools/grep.ts +191 -0
  48. package/src/core/tools/index.ts +3 -6
  49. package/src/core/tools/ls.ts +142 -2
  50. package/src/core/tools/lsp/render.ts +34 -14
  51. package/src/core/tools/notebook.ts +110 -0
  52. package/src/core/tools/output.ts +179 -7
  53. package/src/core/tools/read.ts +122 -9
  54. package/src/core/tools/render-utils.ts +241 -0
  55. package/src/core/tools/renderers.ts +40 -828
  56. package/src/core/tools/review.ts +26 -7
  57. package/src/core/tools/rulebook.ts +3 -1
  58. package/src/core/tools/task/index.ts +18 -3
  59. package/src/core/tools/task/render.ts +7 -2
  60. package/src/core/tools/task/types.ts +1 -1
  61. package/src/core/tools/truncate.ts +27 -1
  62. package/src/core/tools/web-fetch.ts +23 -15
  63. package/src/core/tools/web-search/index.ts +130 -45
  64. package/src/core/tools/web-search/providers/anthropic.ts +7 -2
  65. package/src/core/tools/web-search/providers/exa.ts +2 -1
  66. package/src/core/tools/web-search/providers/perplexity.ts +6 -1
  67. package/src/core/tools/web-search/render.ts +5 -0
  68. package/src/core/tools/web-search/types.ts +13 -0
  69. package/src/core/tools/write.ts +90 -0
  70. package/src/core/voice.ts +1 -1
  71. package/src/lib/worktree/constants.ts +6 -6
  72. package/src/main.ts +1 -1
  73. package/src/modes/interactive/components/assistant-message.ts +1 -1
  74. package/src/modes/interactive/components/custom-message.ts +1 -1
  75. package/src/modes/interactive/components/extensions/inspector-panel.ts +25 -22
  76. package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
  77. package/src/modes/interactive/components/footer.ts +1 -1
  78. package/src/modes/interactive/components/hook-message.ts +1 -1
  79. package/src/modes/interactive/components/model-selector.ts +1 -1
  80. package/src/modes/interactive/components/oauth-selector.ts +1 -1
  81. package/src/modes/interactive/components/settings-defs.ts +49 -0
  82. package/src/modes/interactive/components/status-line.ts +1 -1
  83. package/src/modes/interactive/components/tool-execution.ts +93 -538
  84. package/src/modes/interactive/interactive-mode.ts +19 -7
  85. package/src/modes/print-mode.ts +1 -1
  86. package/src/modes/rpc/rpc-client.ts +1 -1
  87. package/src/modes/rpc/rpc-types.ts +1 -1
  88. package/src/prompts/system-prompt.md +4 -0
  89. package/src/prompts/tools/gemini-image.md +5 -1
  90. package/src/prompts/tools/output.md +4 -0
  91. package/src/prompts/tools/web-fetch.md +1 -0
  92. package/src/prompts/tools/web-search.md +2 -0
  93. package/src/utils/image-convert.ts +8 -2
  94. package/src/utils/image-magick.ts +247 -0
  95. package/src/utils/image-resize.ts +53 -13
package/src/core/sdk.ts CHANGED
@@ -27,8 +27,8 @@
27
27
  */
28
28
 
29
29
  import { join } from "node:path";
30
+ import type { Model } from "@mariozechner/pi-ai";
30
31
  import { Agent, type AgentTool, type ThinkingLevel } from "@oh-my-pi/pi-agent-core";
31
- import type { Model } from "@oh-my-pi/pi-ai";
32
32
  import type { Component } from "@oh-my-pi/pi-tui";
33
33
  import chalk from "chalk";
34
34
  // Import discovery to register all providers on startup
@@ -66,6 +66,7 @@ import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate
66
66
  import { SessionManager } from "./session-manager";
67
67
  import { type Settings, SettingsManager, type SkillsSettings } from "./settings-manager";
68
68
  import { loadSkills as loadSkillsInternal, type Skill } from "./skills";
69
+ import { type FileSlashCommand, loadSlashCommands as loadSlashCommandsInternal } from "./slash-commands";
69
70
  import {
70
71
  buildSystemPrompt as buildSystemPromptInternal,
71
72
  loadProjectContextFiles as loadContextFilesInternal,
@@ -100,6 +101,8 @@ import {
100
101
  lsTool,
101
102
  readOnlyTools,
102
103
  readTool,
104
+ setPreferredImageProvider,
105
+ setPreferredWebSearchProvider,
103
106
  type Tool,
104
107
  type ToolName,
105
108
  warmupLspServers,
@@ -153,6 +156,8 @@ export interface CreateAgentSessionOptions {
153
156
  contextFiles?: Array<{ path: string; content: string }>;
154
157
  /** Prompt templates. Default: discovered from cwd/.omp/prompts/ + agentDir/prompts/ */
155
158
  promptTemplates?: PromptTemplate[];
159
+ /** File-based slash commands. Default: discovered from commands/ directories */
160
+ slashCommands?: FileSlashCommand[];
156
161
 
157
162
  /** Enable MCP server discovery from .mcp.json files. Default: true */
158
163
  enableMCP?: boolean;
@@ -199,6 +204,7 @@ export type { MCPManager, MCPServerConfig, MCPServerConnection, MCPToolsLoadResu
199
204
  export type { PromptTemplate } from "./prompt-templates";
200
205
  export type { Settings, SkillsSettings } from "./settings-manager";
201
206
  export type { Skill } from "./skills";
207
+ export type { FileSlashCommand } from "./slash-commands";
202
208
  export type { Tool } from "./tools/index";
203
209
 
204
210
  export {
@@ -315,6 +321,13 @@ export async function discoverPromptTemplates(cwd?: string, agentDir?: string):
315
321
  });
316
322
  }
317
323
 
324
+ /**
325
+ * Discover file-based slash commands from commands/ directories.
326
+ */
327
+ export function discoverSlashCommands(cwd?: string): FileSlashCommand[] {
328
+ return loadSlashCommandsInternal({ cwd: cwd ?? process.cwd() });
329
+ }
330
+
318
331
  /**
319
332
  * Discover custom commands (TypeScript slash commands) from cwd and agentDir.
320
333
  */
@@ -480,7 +493,7 @@ function createCustomToolsExtension(tools: CustomTool[]): ExtensionFactory {
480
493
  * const { session } = await createAgentSession();
481
494
  *
482
495
  * // With explicit model
483
- * import { getModel } from '@oh-my-pi/pi-ai';
496
+ * import { getModel } from '@mariozechner/pi-ai';
484
497
  * const { session } = await createAgentSession({
485
498
  * model: getModel('anthropic', 'claude-opus-4-5'),
486
499
  * thinkingLevel: 'high',
@@ -517,6 +530,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
517
530
  initializeWithSettings(settingsManager);
518
531
  time("initializeWithSettings");
519
532
 
533
+ // Initialize provider preferences from settings
534
+ setPreferredWebSearchProvider(settingsManager.getWebSearchProvider());
535
+ setPreferredImageProvider(settingsManager.getImageProvider());
536
+
520
537
  const sessionManager = options.sessionManager ?? SessionManager.create(cwd);
521
538
  time("sessionManager");
522
539
 
@@ -625,9 +642,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
625
642
  });
626
643
  time("createAllTools");
627
644
 
628
- const initialActiveToolNames: ToolName[] = options.tools
645
+ // Determine which tools to include based on settings
646
+ let baseToolNames = options.tools
629
647
  ? options.tools.map((t) => t.name).filter((n): n is ToolName => n in allBuiltInToolsMap)
630
- : baseCodingToolNames;
648
+ : [...baseCodingToolNames];
649
+
650
+ // Filter out git tool if disabled in settings
651
+ if (!settingsManager.getGitToolEnabled()) {
652
+ baseToolNames = baseToolNames.filter((name) => name !== "git");
653
+ }
654
+
655
+ const initialActiveToolNames: ToolName[] = baseToolNames;
631
656
  const initialActiveBuiltInTools = initialActiveToolNames.map((name) => allBuiltInToolsMap[name]);
632
657
 
633
658
  // Discover MCP tools from .mcp.json files
@@ -897,6 +922,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
897
922
  const promptTemplates = options.promptTemplates ?? (await discoverPromptTemplates(cwd, agentDir));
898
923
  time("discoverPromptTemplates");
899
924
 
925
+ const slashCommands = options.slashCommands ?? discoverSlashCommands(cwd);
926
+ time("discoverSlashCommands");
927
+
900
928
  const baseSetUIContext = extensionsResult.setUIContext;
901
929
  extensionsResult.setUIContext = (uiContext, hasUI) => {
902
930
  baseSetUIContext(uiContext, hasUI);
@@ -951,6 +979,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
951
979
  settingsManager,
952
980
  scopedModels: options.scopedModels,
953
981
  promptTemplates,
982
+ slashCommands,
954
983
  extensionRunner,
955
984
  customCommands: customCommandsResult.commands,
956
985
  skillsSettings: settingsManager.getSkillsSettings(),
@@ -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
  }
@@ -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");
@@ -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,13 @@
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 { formatErrorMessage, formatMeta } from "./render-utils";
22
26
 
23
27
  // =============================================================================
24
28
  // Types
@@ -191,3 +195,86 @@ export function createAskTool(_cwd: string): AgentTool<typeof askSchema, AskTool
191
195
 
192
196
  /** Default ask tool using process.cwd() - for backwards compatibility (no UI) */
193
197
  export const askTool = createAskTool(process.cwd());
198
+
199
+ // =============================================================================
200
+ // TUI Renderer
201
+ // =============================================================================
202
+
203
+ interface AskRenderArgs {
204
+ question: string;
205
+ options?: Array<{ label: string }>;
206
+ multi?: boolean;
207
+ }
208
+
209
+ export const askToolRenderer = {
210
+ renderCall(args: AskRenderArgs, uiTheme: Theme): Component {
211
+ if (!args.question) {
212
+ return new Text(formatErrorMessage("No question provided", uiTheme), 0, 0);
213
+ }
214
+
215
+ const label = uiTheme.fg("toolTitle", uiTheme.bold("Ask"));
216
+ let text = `${label} ${uiTheme.fg("accent", args.question)}`;
217
+
218
+ const meta: string[] = [];
219
+ if (args.multi) meta.push("multi");
220
+ if (args.options?.length) meta.push(`options:${args.options.length}`);
221
+ text += formatMeta(meta, uiTheme);
222
+
223
+ if (args.options?.length) {
224
+ for (let i = 0; i < args.options.length; i++) {
225
+ const opt = args.options[i];
226
+ const isLast = i === args.options.length - 1;
227
+ const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
228
+ text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg(
229
+ "dim",
230
+ uiTheme.checkbox.unchecked,
231
+ )} ${uiTheme.fg("muted", opt.label)}`;
232
+ }
233
+ }
234
+
235
+ return new Text(text, 0, 0);
236
+ },
237
+
238
+ renderResult(
239
+ result: { content: Array<{ type: string; text?: string }>; details?: AskToolDetails },
240
+ _opts: RenderResultOptions,
241
+ uiTheme: Theme,
242
+ ): Component {
243
+ const { details } = result;
244
+ if (!details) {
245
+ const txt = result.content[0];
246
+ return new Text(txt?.type === "text" && txt.text ? txt.text : "", 0, 0);
247
+ }
248
+
249
+ const hasSelection = details.customInput || details.selectedOptions.length > 0;
250
+ const statusIcon = hasSelection
251
+ ? uiTheme.styledSymbol("status.success", "success")
252
+ : uiTheme.styledSymbol("status.warning", "warning");
253
+
254
+ let text = `${statusIcon} ${uiTheme.fg("accent", details.question)}`;
255
+
256
+ if (details.customInput) {
257
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol(
258
+ "status.success",
259
+ "success",
260
+ )} ${uiTheme.fg("toolOutput", details.customInput)}`;
261
+ } else if (details.selectedOptions.length > 0) {
262
+ const selected = details.selectedOptions;
263
+ for (let i = 0; i < selected.length; i++) {
264
+ const isLast = i === selected.length - 1;
265
+ const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
266
+ text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg(
267
+ "success",
268
+ uiTheme.checkbox.checked,
269
+ )} ${uiTheme.fg("toolOutput", selected[i])}`;
270
+ }
271
+ } else {
272
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol(
273
+ "status.warning",
274
+ "warning",
275
+ )} ${uiTheme.fg("warning", "Cancelled")}`;
276
+ }
277
+
278
+ return new Text(text, 0, 0);
279
+ },
280
+ };
@@ -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)/,
@@ -1,7 +1,12 @@
1
1
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
2
+ import type { Component } from "@oh-my-pi/pi-tui";
3
+ import { Text } from "@oh-my-pi/pi-tui";
2
4
  import { Type } from "@sinclair/typebox";
5
+ import type { Theme } from "../../modes/interactive/theme/theme";
3
6
  import bashDescription from "../../prompts/tools/bash.md" with { type: "text" };
4
7
  import { executeBash } from "../bash-executor";
8
+ import type { RenderResultOptions } from "../custom-tools/types";
9
+ import { formatBytes, wrapBrackets } from "./render-utils";
5
10
  import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateTail } from "./truncate";
6
11
 
7
12
  const bashSchema = Type.Object({
@@ -89,3 +94,104 @@ export function createBashTool(cwd: string): AgentTool<typeof bashSchema> {
89
94
 
90
95
  /** Default bash tool using process.cwd() - for backwards compatibility */
91
96
  export const bashTool = createBashTool(process.cwd());
97
+
98
+ // =============================================================================
99
+ // TUI Renderer
100
+ // =============================================================================
101
+
102
+ interface BashRenderArgs {
103
+ command?: string;
104
+ timeout?: number;
105
+ }
106
+
107
+ interface BashRenderContext {
108
+ /** Visual lines for truncated output (pre-computed by tool-execution) */
109
+ visualLines?: string[];
110
+ /** Number of lines skipped */
111
+ skippedCount?: number;
112
+ /** Total visual lines */
113
+ totalVisualLines?: number;
114
+ }
115
+
116
+ export const bashToolRenderer = {
117
+ renderCall(args: BashRenderArgs, uiTheme: Theme): Component {
118
+ const command = args.command || uiTheme.format.ellipsis;
119
+ const text = uiTheme.fg("toolTitle", uiTheme.bold(`$ ${command}`));
120
+ return new Text(text, 0, 0);
121
+ },
122
+
123
+ renderResult(
124
+ result: {
125
+ content: Array<{ type: string; text?: string }>;
126
+ details?: BashToolDetails;
127
+ },
128
+ options: RenderResultOptions & { renderContext?: BashRenderContext },
129
+ uiTheme: Theme,
130
+ ): Component {
131
+ const { expanded, renderContext } = options;
132
+ const details = result.details;
133
+ const lines: string[] = [];
134
+
135
+ // Get output text
136
+ const textContent = result.content?.find((c) => c.type === "text")?.text ?? "";
137
+ const output = textContent.trim();
138
+
139
+ if (output) {
140
+ if (expanded) {
141
+ // Show all lines when expanded
142
+ const styledOutput = output
143
+ .split("\n")
144
+ .map((line) => uiTheme.fg("toolOutput", line))
145
+ .join("\n");
146
+ lines.push(styledOutput);
147
+ } else if (renderContext?.visualLines) {
148
+ // Use pre-computed visual lines from tool-execution
149
+ const { visualLines, skippedCount = 0, totalVisualLines = visualLines.length } = renderContext;
150
+ if (skippedCount > 0) {
151
+ lines.push(
152
+ uiTheme.fg(
153
+ "dim",
154
+ `${uiTheme.format.ellipsis} (${skippedCount} earlier lines, showing ${visualLines.length} of ${totalVisualLines}) (ctrl+o to expand)`,
155
+ ),
156
+ );
157
+ }
158
+ lines.push(...visualLines);
159
+ } else {
160
+ // Fallback: show first few lines
161
+ const outputLines = output.split("\n");
162
+ const maxLines = 5;
163
+ const displayLines = outputLines.slice(0, maxLines);
164
+ const remaining = outputLines.length - maxLines;
165
+
166
+ lines.push(...displayLines.map((line) => uiTheme.fg("toolOutput", line)));
167
+ if (remaining > 0) {
168
+ lines.push(uiTheme.fg("dim", `${uiTheme.format.ellipsis} (${remaining} more lines) (ctrl+o to expand)`));
169
+ }
170
+ }
171
+ }
172
+
173
+ // Truncation warnings
174
+ const truncation = details?.truncation;
175
+ const fullOutputPath = details?.fullOutputPath;
176
+ if (truncation?.truncated || fullOutputPath) {
177
+ const warnings: string[] = [];
178
+ if (fullOutputPath) {
179
+ warnings.push(`Full output: ${fullOutputPath}`);
180
+ }
181
+ if (truncation?.truncated) {
182
+ if (truncation.truncatedBy === "lines") {
183
+ warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
184
+ } else {
185
+ warnings.push(
186
+ `Truncated: ${truncation.outputLines} lines shown (${formatBytes(
187
+ truncation.maxBytes ?? DEFAULT_MAX_BYTES,
188
+ )} limit)`,
189
+ );
190
+ }
191
+ }
192
+ lines.push(uiTheme.fg("warning", wrapBrackets(warnings.join(". "), uiTheme)));
193
+ }
194
+
195
+ return new Text(lines.join("\n"), 0, 0);
196
+ },
197
+ };