@oh-my-pi/pi-coding-agent 3.34.0 → 3.36.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.
@@ -14,6 +14,9 @@ import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateTail } fr
14
14
  const bashSchema = Type.Object({
15
15
  command: Type.String({ description: "Bash command to execute" }),
16
16
  timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
17
+ workdir: Type.Optional(
18
+ Type.String({ description: "Working directory for the command (default: current directory)" }),
19
+ ),
17
20
  });
18
21
 
19
22
  export interface BashToolDetails {
@@ -29,7 +32,7 @@ export function createBashTool(session: ToolSession): AgentTool<typeof bashSchem
29
32
  parameters: bashSchema,
30
33
  execute: async (
31
34
  _toolCallId: string,
32
- { command, timeout }: { command: string; timeout?: number },
35
+ { command, timeout, workdir }: { command: string; timeout?: number; workdir?: string },
33
36
  signal?: AbortSignal,
34
37
  onUpdate?,
35
38
  ctx?: AgentToolContext,
@@ -53,7 +56,7 @@ export function createBashTool(session: ToolSession): AgentTool<typeof bashSchem
53
56
  let currentOutput = "";
54
57
 
55
58
  const result = await executeBash(command, {
56
- cwd: session.cwd,
59
+ cwd: workdir ?? session.cwd,
57
60
  timeout: timeout ? timeout * 1000 : undefined, // Convert to milliseconds
58
61
  signal,
59
62
  onChunk: (chunk) => {
@@ -117,6 +120,7 @@ export function createBashTool(session: ToolSession): AgentTool<typeof bashSchem
117
120
  interface BashRenderArgs {
118
121
  command?: string;
119
122
  timeout?: number;
123
+ workdir?: string;
120
124
  }
121
125
 
122
126
  interface BashRenderContext {
@@ -132,7 +136,11 @@ export const bashToolRenderer = {
132
136
  renderCall(args: BashRenderArgs, uiTheme: Theme): Component {
133
137
  const ui = createToolUIKit(uiTheme);
134
138
  const command = args.command || uiTheme.format.ellipsis;
135
- const text = ui.title(`$ ${command}`);
139
+ const prompt = uiTheme.fg("accent", "$");
140
+ const cmdText = args.workdir
141
+ ? `${prompt} ${uiTheme.fg("dim", `cd ${args.workdir} &&`)} ${command}`
142
+ : `${prompt} ${command}`;
143
+ const text = ui.title(cmdText);
136
144
  return new Text(text, 0, 0);
137
145
  },
138
146
 
@@ -0,0 +1,500 @@
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";
4
+ import { Type } from "@sinclair/typebox";
5
+ import type { Theme } from "../../modes/interactive/theme/theme";
6
+ import calculatorDescription from "../../prompts/tools/calculator.md" with { type: "text" };
7
+ import type { RenderResultOptions } from "../custom-tools/types";
8
+ import { untilAborted } from "../utils";
9
+ import type { ToolSession } from "./index";
10
+ import {
11
+ formatCount,
12
+ formatEmptyMessage,
13
+ formatExpandHint,
14
+ formatMeta,
15
+ formatMoreItems,
16
+ PREVIEW_LIMITS,
17
+ TRUNCATE_LENGTHS,
18
+ truncate,
19
+ } from "./render-utils";
20
+
21
+ // =============================================================================
22
+ // Token Types
23
+ // =============================================================================
24
+
25
+ /** Supported arithmetic operators (** is exponentiation). */
26
+ type Operator = "+" | "-" | "*" | "/" | "%" | "**";
27
+
28
+ /**
29
+ * Lexer token variants:
30
+ * - number: parsed numeric value with original string for error messages
31
+ * - operator: arithmetic operator
32
+ * - paren: grouping parenthesis
33
+ */
34
+ type Token =
35
+ | { type: "number"; value: number; raw: string }
36
+ | { type: "operator"; value: Operator }
37
+ | { type: "paren"; value: "(" | ")" };
38
+
39
+ const calculatorSchema = Type.Object({
40
+ calculations: Type.Array(
41
+ Type.Object({
42
+ expression: Type.String({ description: "Math expression to evaluate" }),
43
+ prefix: Type.String({ description: "Text to prepend to the result" }),
44
+ suffix: Type.String({ description: "Text to append to the result" }),
45
+ }),
46
+ { description: "List of calculations to evaluate", minItems: 1 },
47
+ ),
48
+ });
49
+
50
+ export interface CalculatorToolDetails {
51
+ results: Array<{ expression: string; value: number; output: string }>;
52
+ }
53
+
54
+ // =============================================================================
55
+ // Character classification helpers for numeric literal parsing
56
+ // =============================================================================
57
+
58
+ function isDigit(ch: string): boolean {
59
+ return ch >= "0" && ch <= "9";
60
+ }
61
+
62
+ function isHexDigit(ch: string): boolean {
63
+ return (ch >= "0" && ch <= "9") || (ch >= "a" && ch <= "f") || (ch >= "A" && ch <= "F");
64
+ }
65
+
66
+ function isBinaryDigit(ch: string): boolean {
67
+ return ch === "0" || ch === "1";
68
+ }
69
+
70
+ function isOctalDigit(ch: string): boolean {
71
+ return ch >= "0" && ch <= "7";
72
+ }
73
+
74
+ // =============================================================================
75
+ // Tokenizer
76
+ // =============================================================================
77
+
78
+ /**
79
+ * Tokenize a math expression into numbers, operators, and parentheses.
80
+ *
81
+ * Number formats supported:
82
+ * - Decimal: 123, 3.14, .5
83
+ * - Scientific: 1e10, 2.5E-3
84
+ * - Hexadecimal: 0xFF
85
+ * - Binary: 0b1010
86
+ * - Octal: 0o755
87
+ */
88
+ function tokenizeExpression(expression: string): Token[] {
89
+ const tokens: Token[] = [];
90
+ let i = 0;
91
+
92
+ while (i < expression.length) {
93
+ const ch = expression[i];
94
+
95
+ // Skip whitespace
96
+ if (ch.trim() === "") {
97
+ i += 1;
98
+ continue;
99
+ }
100
+
101
+ if (ch === "(" || ch === ")") {
102
+ tokens.push({ type: "paren", value: ch });
103
+ i += 1;
104
+ continue;
105
+ }
106
+
107
+ // Check ** before single * to handle exponentiation
108
+ if (ch === "*" && expression[i + 1] === "*") {
109
+ tokens.push({ type: "operator", value: "**" });
110
+ i += 2;
111
+ continue;
112
+ }
113
+
114
+ if (ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%") {
115
+ tokens.push({ type: "operator", value: ch });
116
+ i += 1;
117
+ continue;
118
+ }
119
+
120
+ // Number parsing: starts with digit or decimal point followed by digit
121
+ const next = expression[i + 1];
122
+ const numberStart = isDigit(ch) || (ch === "." && next !== undefined && isDigit(next));
123
+ if (!numberStart) {
124
+ throw new Error(`Invalid character "${ch}" in expression`);
125
+ }
126
+
127
+ const start = i;
128
+
129
+ // Handle prefixed literals (0x, 0b, 0o)
130
+ if (ch === "0" && next !== undefined) {
131
+ const prefix = next.toLowerCase();
132
+ if (prefix === "x" || prefix === "b" || prefix === "o") {
133
+ i += 2; // Skip "0x" / "0b" / "0o"
134
+ let hasDigit = false;
135
+ while (i < expression.length) {
136
+ const digit = expression[i];
137
+ const valid =
138
+ prefix === "x" ? isHexDigit(digit) : prefix === "b" ? isBinaryDigit(digit) : isOctalDigit(digit);
139
+ if (!valid) break;
140
+ hasDigit = true;
141
+ i += 1;
142
+ }
143
+
144
+ if (!hasDigit) {
145
+ throw new Error(`Invalid numeric literal starting at "${expression.slice(start, i)}"`);
146
+ }
147
+
148
+ const raw = expression.slice(start, i);
149
+ const value = Number(raw); // JS Number() handles 0x/0b/0o natively
150
+ if (!Number.isFinite(value)) {
151
+ throw new Error(`Invalid number "${raw}"`);
152
+ }
153
+ tokens.push({ type: "number", value, raw });
154
+ continue;
155
+ }
156
+ }
157
+
158
+ // Parse decimal number: integer part
159
+ let hasDigits = false;
160
+ while (i < expression.length && isDigit(expression[i])) {
161
+ hasDigits = true;
162
+ i += 1;
163
+ }
164
+
165
+ // Fractional part
166
+ if (expression[i] === ".") {
167
+ i += 1;
168
+ while (i < expression.length && isDigit(expression[i])) {
169
+ hasDigits = true;
170
+ i += 1;
171
+ }
172
+ }
173
+
174
+ if (!hasDigits) {
175
+ throw new Error(`Invalid number starting at "${expression.slice(start, i + 1)}"`);
176
+ }
177
+
178
+ // Scientific notation exponent (e.g., 1e10, 2.5E-3)
179
+ if (expression[i] === "e" || expression[i] === "E") {
180
+ i += 1;
181
+ if (expression[i] === "+" || expression[i] === "-") {
182
+ i += 1;
183
+ }
184
+
185
+ let hasExponentDigits = false;
186
+ while (i < expression.length && isDigit(expression[i])) {
187
+ hasExponentDigits = true;
188
+ i += 1;
189
+ }
190
+
191
+ if (!hasExponentDigits) {
192
+ throw new Error(`Invalid exponent in "${expression.slice(start, i)}"`);
193
+ }
194
+ }
195
+
196
+ const raw = expression.slice(start, i);
197
+ const value = Number(raw);
198
+ if (!Number.isFinite(value)) {
199
+ throw new Error(`Invalid number "${raw}"`);
200
+ }
201
+ tokens.push({ type: "number", value, raw });
202
+ }
203
+
204
+ return tokens;
205
+ }
206
+
207
+ // =============================================================================
208
+ // Recursive Descent Parser
209
+ // =============================================================================
210
+
211
+ /**
212
+ * Recursive descent parser for arithmetic expressions.
213
+ *
214
+ * Operator precedence (lowest to highest):
215
+ * 1. Addition, subtraction (+, -)
216
+ * 2. Multiplication, division, modulo (*, /, %)
217
+ * 3. Unary plus/minus (+x, -x)
218
+ * 4. Exponentiation (**)
219
+ * 5. Parentheses and literals
220
+ *
221
+ * Each precedence level has its own parse method. Lower precedence methods
222
+ * call higher precedence methods, building the AST implicitly through
223
+ * the call stack.
224
+ */
225
+ class ExpressionParser {
226
+ private index = 0;
227
+
228
+ constructor(private readonly tokens: Token[]) {}
229
+
230
+ /** Parse the full expression and ensure all tokens are consumed. */
231
+ parse(): number {
232
+ const value = this.parseExpression();
233
+ if (this.index < this.tokens.length) {
234
+ throw new Error("Unexpected token in expression");
235
+ }
236
+ return value;
237
+ }
238
+
239
+ /**
240
+ * Parse addition and subtraction (lowest precedence).
241
+ * Left-associative: 1 - 2 - 3 = (1 - 2) - 3
242
+ */
243
+ private parseExpression(): number {
244
+ let value = this.parseTerm();
245
+ while (true) {
246
+ if (this.matchOperator("+")) {
247
+ value += this.parseTerm();
248
+ continue;
249
+ }
250
+ if (this.matchOperator("-")) {
251
+ value -= this.parseTerm();
252
+ continue;
253
+ }
254
+ break;
255
+ }
256
+ return value;
257
+ }
258
+
259
+ /**
260
+ * Parse multiplication, division, and modulo.
261
+ * Left-associative: 8 / 4 / 2 = (8 / 4) / 2
262
+ */
263
+ private parseTerm(): number {
264
+ let value = this.parseUnary();
265
+ while (true) {
266
+ if (this.matchOperator("*")) {
267
+ value *= this.parseUnary();
268
+ continue;
269
+ }
270
+ if (this.matchOperator("/")) {
271
+ value /= this.parseUnary();
272
+ continue;
273
+ }
274
+ if (this.matchOperator("%")) {
275
+ value %= this.parseUnary();
276
+ continue;
277
+ }
278
+ break;
279
+ }
280
+ return value;
281
+ }
282
+
283
+ /**
284
+ * Parse unary + and - operators.
285
+ * Recursive to handle chained unary: --x, +-x
286
+ */
287
+ private parseUnary(): number {
288
+ if (this.matchOperator("+")) {
289
+ return this.parseUnary();
290
+ }
291
+ if (this.matchOperator("-")) {
292
+ return -this.parseUnary();
293
+ }
294
+ return this.parsePower();
295
+ }
296
+
297
+ /**
298
+ * Parse exponentiation operator.
299
+ * Right-associative: 2 ** 3 ** 2 = 2 ** (3 ** 2) = 512
300
+ * Achieved by recursive call to parsePower for the right operand.
301
+ */
302
+ private parsePower(): number {
303
+ let value = this.parsePrimary();
304
+ if (this.matchOperator("**")) {
305
+ value = value ** this.parsePower(); // Right-associative via recursion
306
+ }
307
+ return value;
308
+ }
309
+
310
+ /**
311
+ * Parse primary expressions: number literals and parenthesized subexpressions.
312
+ * Parentheses restart parsing at lowest precedence (parseExpression).
313
+ */
314
+ private parsePrimary(): number {
315
+ const token = this.peek();
316
+ if (!token) {
317
+ throw new Error("Unexpected end of expression");
318
+ }
319
+
320
+ if (token.type === "number") {
321
+ this.index += 1;
322
+ return token.value;
323
+ }
324
+
325
+ if (token.type === "paren" && token.value === "(") {
326
+ this.index += 1;
327
+ const value = this.parseExpression(); // Reset to lowest precedence
328
+ if (!this.matchParen(")")) {
329
+ throw new Error("Missing closing parenthesis");
330
+ }
331
+ return value;
332
+ }
333
+
334
+ throw new Error("Unexpected token in expression");
335
+ }
336
+
337
+ /** Consume operator if it matches, advancing the token index. */
338
+ private matchOperator(value: Operator): boolean {
339
+ const token = this.tokens[this.index];
340
+ if (token && token.type === "operator" && token.value === value) {
341
+ this.index += 1;
342
+ return true;
343
+ }
344
+ return false;
345
+ }
346
+
347
+ /** Consume parenthesis if it matches, advancing the token index. */
348
+ private matchParen(value: "(" | ")"): boolean {
349
+ const token = this.tokens[this.index];
350
+ if (token && token.type === "paren" && token.value === value) {
351
+ this.index += 1;
352
+ return true;
353
+ }
354
+ return false;
355
+ }
356
+
357
+ /** Look at current token without consuming it. */
358
+ private peek(): Token | undefined {
359
+ return this.tokens[this.index];
360
+ }
361
+ }
362
+
363
+ // =============================================================================
364
+ // Expression Evaluator
365
+ // =============================================================================
366
+
367
+ /**
368
+ * Evaluate a math expression string and return the numeric result.
369
+ *
370
+ * Pipeline: expression string -> tokens -> parse tree (implicit) -> value
371
+ *
372
+ * @throws Error on syntax errors, empty expressions, or non-finite results (Infinity, NaN)
373
+ */
374
+ function evaluateExpression(expression: string): number {
375
+ const tokens = tokenizeExpression(expression);
376
+ if (tokens.length === 0) {
377
+ throw new Error("Expression is empty");
378
+ }
379
+ const parser = new ExpressionParser(tokens);
380
+ const value = parser.parse();
381
+ if (!Number.isFinite(value)) {
382
+ throw new Error("Expression result is not a finite number");
383
+ }
384
+ // Normalize -0 to 0 for consistent output
385
+ return Object.is(value, -0) ? 0 : value;
386
+ }
387
+
388
+ function formatResult(value: number): string {
389
+ return String(value);
390
+ }
391
+
392
+ export function createCalculatorTool(_session: ToolSession): AgentTool<typeof calculatorSchema> {
393
+ return {
394
+ name: "calc",
395
+ label: "Calc",
396
+ description: calculatorDescription,
397
+ parameters: calculatorSchema,
398
+ execute: async (
399
+ _toolCallId: string,
400
+ { calculations }: { calculations: Array<{ expression: string; prefix: string; suffix: string }> },
401
+ signal?: AbortSignal,
402
+ ) => {
403
+ return untilAborted(signal, async () => {
404
+ const results = calculations.map((calc) => {
405
+ const value = evaluateExpression(calc.expression);
406
+ const output = `${calc.prefix}${formatResult(value)}${calc.suffix}`;
407
+ return { expression: calc.expression, value, output };
408
+ });
409
+
410
+ const outputText = results.map((result) => result.output).join("\n");
411
+ return {
412
+ content: [{ type: "text", text: outputText }],
413
+ details: { results },
414
+ };
415
+ });
416
+ },
417
+ };
418
+ }
419
+
420
+ // =============================================================================
421
+ // TUI Renderer
422
+ // =============================================================================
423
+
424
+ interface CalculatorRenderArgs {
425
+ calculations?: Array<{ expression: string; prefix?: string; suffix?: string }>;
426
+ }
427
+
428
+ const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS;
429
+
430
+ /**
431
+ * TUI renderer for calculator tool calls and results.
432
+ * Handles both collapsed (preview) and expanded (full) display modes.
433
+ */
434
+ export const calculatorToolRenderer = {
435
+ /**
436
+ * Render the tool call header showing the first expression and count.
437
+ * Format: "Calc <expression> (N calcs)"
438
+ */
439
+ renderCall(args: CalculatorRenderArgs, uiTheme: Theme): Component {
440
+ const label = uiTheme.fg("toolTitle", uiTheme.bold("Calc"));
441
+ const count = args.calculations?.length ?? 0;
442
+ const firstExpression = args.calculations?.[0]?.expression;
443
+ let text = label;
444
+ if (firstExpression) {
445
+ text += ` ${uiTheme.fg("accent", truncate(firstExpression, TRUNCATE_LENGTHS.TITLE, "..."))}`;
446
+ }
447
+ const meta: string[] = [];
448
+ if (count > 0) meta.push(formatCount("calc", count));
449
+ text += formatMeta(meta, uiTheme);
450
+ return new Text(text, 0, 0);
451
+ },
452
+
453
+ /**
454
+ * Render calculation results as a tree list.
455
+ * Collapsed mode shows first N items with expand hint; expanded shows all.
456
+ */
457
+ renderResult(
458
+ result: { content: Array<{ type: string; text?: string }>; details?: CalculatorToolDetails },
459
+ { expanded }: RenderResultOptions,
460
+ uiTheme: Theme,
461
+ ): Component {
462
+ const details = result.details;
463
+ const textContent = result.content?.find((c) => c.type === "text")?.text ?? "";
464
+
465
+ // Prefer structured details; fall back to parsing text content
466
+ let outputs = details?.results?.map((entry) => entry.output) ?? [];
467
+ if (outputs.length === 0 && textContent.trim()) {
468
+ outputs = textContent.split("\n").filter((line) => line.trim().length > 0);
469
+ }
470
+
471
+ if (outputs.length === 0) {
472
+ return new Text(formatEmptyMessage("No results", uiTheme), 0, 0);
473
+ }
474
+
475
+ // Limit visible items in collapsed mode
476
+ const maxItems = expanded ? outputs.length : Math.min(outputs.length, COLLAPSED_LIST_LIMIT);
477
+ const hasMore = outputs.length > maxItems;
478
+ const icon = uiTheme.styledSymbol("status.success", "success");
479
+ const summary = uiTheme.fg("dim", formatCount("result", outputs.length));
480
+ const expandHint = formatExpandHint(expanded, hasMore, uiTheme);
481
+ let text = `${icon} ${summary}${expandHint}`;
482
+
483
+ // Render each result as a tree branch
484
+ for (let i = 0; i < maxItems; i += 1) {
485
+ const isLast = i === maxItems - 1 && !hasMore;
486
+ const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
487
+ text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("toolOutput", outputs[i])}`;
488
+ }
489
+
490
+ // Show overflow indicator for collapsed mode
491
+ if (hasMore) {
492
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg(
493
+ "muted",
494
+ formatMoreItems(outputs.length - maxItems, "result", uiTheme),
495
+ )}`;
496
+ }
497
+
498
+ return new Text(text, 0, 0);
499
+ },
500
+ };
@@ -229,6 +229,7 @@ function formatMetadataLine(lineCount: number | null, language: string | undefin
229
229
  }
230
230
 
231
231
  export const editToolRenderer = {
232
+ mergeCallAndResult: true,
232
233
  renderCall(args: EditRenderArgs, uiTheme: Theme): Component {
233
234
  const ui = createToolUIKit(uiTheme);
234
235
  const rawPath = args.file_path || args.path || "";
@@ -196,7 +196,7 @@ export function createGrepTool(session: ToolSession): AgentTool<typeof grepSchem
196
196
  args.push("--type", type);
197
197
  }
198
198
 
199
- args.push(pattern, searchPath);
199
+ args.push("--", pattern, searchPath);
200
200
 
201
201
  const child: Subprocess = Bun.spawn([rgPath, ...args], {
202
202
  stdin: "ignore",
@@ -20,6 +20,7 @@ describe("createTools", () => {
20
20
 
21
21
  // Core tools should always be present
22
22
  expect(names).toContain("bash");
23
+ expect(names).toContain("calc");
23
24
  expect(names).toContain("read");
24
25
  expect(names).toContain("edit");
25
26
  expect(names).toContain("write");
@@ -162,6 +163,7 @@ describe("createTools", () => {
162
163
  const expectedTools = [
163
164
  "ask",
164
165
  "bash",
166
+ "calc",
165
167
  "ssh",
166
168
  "edit",
167
169
  "find",
@@ -1,5 +1,6 @@
1
1
  export { type AskToolDetails, askTool, createAskTool } from "./ask";
2
2
  export { type BashToolDetails, createBashTool } from "./bash";
3
+ export { type CalculatorToolDetails, createCalculatorTool } from "./calculator";
3
4
  export { createCompleteTool } from "./complete";
4
5
  export { createEditTool } from "./edit";
5
6
  // Exa MCP tools (22 tools)
@@ -57,6 +58,7 @@ import type { EventBus } from "../event-bus";
57
58
  import type { BashInterceptorRule } from "../settings-manager";
58
59
  import { createAskTool } from "./ask";
59
60
  import { createBashTool } from "./bash";
61
+ import { createCalculatorTool } from "./calculator";
60
62
  import { createCompleteTool } from "./complete";
61
63
  import { createEditTool } from "./edit";
62
64
  import { createFindTool } from "./find";
@@ -98,6 +100,8 @@ export interface ToolSession {
98
100
  getSessionSpawns: () => string | null;
99
101
  /** Get resolved model string if explicitly set for this session */
100
102
  getModelString?: () => string | undefined;
103
+ /** Get the current session model string, regardless of how it was chosen */
104
+ getActiveModelString?: () => string | undefined;
101
105
  /** Settings manager (optional) */
102
106
  settings?: {
103
107
  getImageAutoResize(): boolean;
@@ -117,6 +121,7 @@ type ToolFactory = (session: ToolSession) => Tool | null | Promise<Tool | null>;
117
121
  export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
118
122
  ask: createAskTool,
119
123
  bash: createBashTool,
124
+ calc: createCalculatorTool,
120
125
  ssh: createSshTool,
121
126
  edit: createEditTool,
122
127
  find: createFindTool,
@@ -9,6 +9,7 @@ import type { Theme } from "../../modes/interactive/theme/theme";
9
9
  import type { RenderResultOptions } from "../custom-tools/types";
10
10
  import { askToolRenderer } from "./ask";
11
11
  import { bashToolRenderer } from "./bash";
12
+ import { calculatorToolRenderer } from "./calculator";
12
13
  import { editToolRenderer } from "./edit";
13
14
  import { findToolRenderer } from "./find";
14
15
  import { grepToolRenderer } from "./grep";
@@ -31,11 +32,13 @@ type ToolRenderer = {
31
32
  theme: Theme,
32
33
  args?: unknown,
33
34
  ) => Component;
35
+ mergeCallAndResult?: boolean;
34
36
  };
35
37
 
36
38
  export const toolRenderers: Record<string, ToolRenderer> = {
37
39
  ask: askToolRenderer as ToolRenderer,
38
40
  bash: bashToolRenderer as ToolRenderer,
41
+ calc: calculatorToolRenderer as ToolRenderer,
39
42
  edit: editToolRenderer as ToolRenderer,
40
43
  find: findToolRenderer as ToolRenderer,
41
44
  grep: grepToolRenderer as ToolRenderer,
@@ -135,7 +135,12 @@ export async function createTaskTool(
135
135
  const startTime = Date.now();
136
136
  const { agents, projectAgentsDir } = await discoverAgents(session.cwd);
137
137
  const { agent: agentName, context, model, output: outputSchema } = params;
138
- const modelOverride = model ?? session.getModelString?.();
138
+
139
+ const isDefaultModelAlias = (value: string | undefined): boolean => {
140
+ if (!value) return true;
141
+ const normalized = value.trim().toLowerCase();
142
+ return normalized === "default" || normalized === "pi/default" || normalized === "omp/default";
143
+ };
139
144
 
140
145
  // Validate agent exists
141
146
  const agent = getAgent(agents, agentName);
@@ -156,6 +161,10 @@ export async function createTaskTool(
156
161
  };
157
162
  }
158
163
 
164
+ const shouldInheritSessionModel = model === undefined && isDefaultModelAlias(agent.model);
165
+ const sessionModel = shouldInheritSessionModel ? session.getActiveModelString?.() : undefined;
166
+ const modelOverride = model ?? sessionModel ?? session.getModelString?.();
167
+
159
168
  // Handle empty or missing tasks
160
169
  if (!params.tasks || params.tasks.length === 0) {
161
170
  return {
@@ -8,7 +8,7 @@
8
8
  * - Fuzzy match: "opus" → "p-anthropic/claude-opus-4-5"
9
9
  * - Comma fallback: "gpt, opus" → tries gpt first, then opus
10
10
  * - "default" → undefined (use system default)
11
- * - "omp/slow" → configured slow model from settings
11
+ * - "omp/slow" or "pi/slow" → configured slow model from settings
12
12
  */
13
13
 
14
14
  import { type Settings, settingsCapability } from "../../../capability/settings";
@@ -145,9 +145,10 @@ export function resolveModelPattern(pattern: string | undefined, availableModels
145
145
  .filter(Boolean);
146
146
 
147
147
  for (const p of patterns) {
148
- // Handle omp/<role> aliases - looks up role in settings.modelRoles
149
- if (p.toLowerCase().startsWith("omp/")) {
150
- const role = p.slice(4); // Remove "omp/" prefix
148
+ // Handle omp/<role> or pi/<role> aliases - looks up role in settings.modelRoles
149
+ const lower = p.toLowerCase();
150
+ if (lower.startsWith("omp/") || lower.startsWith("pi/")) {
151
+ const role = lower.startsWith("omp/") ? p.slice(4) : p.slice(3);
151
152
  const resolved = resolveOmpAlias(role, models);
152
153
  if (resolved) return resolved;
153
154
  continue; // Role not configured, try next pattern