@oh-my-pi/pi-coding-agent 3.35.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.
- package/CHANGELOG.md +18 -0
- package/README.md +7 -2
- package/package.json +5 -5
- package/src/core/agent-session.ts +8 -8
- package/src/core/auth-storage.ts +293 -28
- package/src/core/model-registry.ts +7 -8
- package/src/core/sdk.ts +5 -4
- package/src/core/system-prompt.ts +1 -0
- package/src/core/title-generator.ts +3 -1
- package/src/core/tools/bash.ts +11 -3
- package/src/core/tools/calculator.ts +500 -0
- package/src/core/tools/index.test.ts +2 -0
- package/src/core/tools/index.ts +3 -0
- package/src/core/tools/renderers.ts +2 -0
- package/src/core/tools/web-search/auth.ts +13 -5
- package/src/core/tools/web-search/types.ts +11 -7
- package/src/modes/interactive/components/oauth-selector.ts +1 -2
- package/src/modes/interactive/interactive-mode.ts +7 -5
- package/src/prompts/tools/ask.md +11 -5
- package/src/prompts/tools/bash.md +1 -0
- package/src/prompts/tools/calculator.md +8 -0
package/src/core/tools/bash.ts
CHANGED
|
@@ -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
|
|
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
|
+
};
|
|
@@ -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",
|
package/src/core/tools/index.ts
CHANGED
|
@@ -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";
|
|
@@ -119,6 +121,7 @@ type ToolFactory = (session: ToolSession) => Tool | null | Promise<Tool | null>;
|
|
|
119
121
|
export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
|
|
120
122
|
ask: createAskTool,
|
|
121
123
|
bash: createBashTool,
|
|
124
|
+
calc: createCalculatorTool,
|
|
122
125
|
ssh: createSshTool,
|
|
123
126
|
edit: createEditTool,
|
|
124
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";
|
|
@@ -37,6 +38,7 @@ type ToolRenderer = {
|
|
|
37
38
|
export const toolRenderers: Record<string, ToolRenderer> = {
|
|
38
39
|
ask: askToolRenderer as ToolRenderer,
|
|
39
40
|
bash: bashToolRenderer as ToolRenderer,
|
|
41
|
+
calc: calculatorToolRenderer as ToolRenderer,
|
|
40
42
|
edit: editToolRenderer as ToolRenderer,
|
|
41
43
|
find: findToolRenderer as ToolRenderer,
|
|
42
44
|
grep: grepToolRenderer as ToolRenderer,
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import * as os from "node:os";
|
|
12
12
|
import * as path from "node:path";
|
|
13
13
|
import { getConfigDirPaths } from "../../../config";
|
|
14
|
-
import type { AnthropicAuthConfig, AuthJson, ModelsJson } from "./types";
|
|
14
|
+
import type { AnthropicAuthConfig, AnthropicOAuthCredential, AuthJson, ModelsJson } from "./types";
|
|
15
15
|
|
|
16
16
|
const DEFAULT_BASE_URL = "https://api.anthropic.com";
|
|
17
17
|
|
|
@@ -76,6 +76,11 @@ export function isOAuthToken(apiKey: string): boolean {
|
|
|
76
76
|
return apiKey.includes("sk-ant-oat");
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
function normalizeAnthropicOAuthCredentials(entry: AuthJson["anthropic"] | undefined): AnthropicOAuthCredential[] {
|
|
80
|
+
if (!entry) return [];
|
|
81
|
+
return Array.isArray(entry) ? entry : [entry];
|
|
82
|
+
}
|
|
83
|
+
|
|
79
84
|
/**
|
|
80
85
|
* Find Anthropic auth config using 4-tier priority:
|
|
81
86
|
* 1. ANTHROPIC_SEARCH_API_KEY / ANTHROPIC_SEARCH_BASE_URL
|
|
@@ -126,13 +131,16 @@ export async function findAnthropicAuth(): Promise<AnthropicAuthConfig | null> {
|
|
|
126
131
|
}
|
|
127
132
|
|
|
128
133
|
// 3. OAuth credentials in auth.json (with 5-minute expiry buffer, check all config dirs)
|
|
134
|
+
const expiryBuffer = 5 * 60 * 1000; // 5 minutes
|
|
135
|
+
const now = Date.now();
|
|
129
136
|
for (const configDir of configDirs) {
|
|
130
137
|
const authJson = await readJson<AuthJson>(path.join(configDir, "auth.json"));
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (
|
|
138
|
+
const credentials = normalizeAnthropicOAuthCredentials(authJson?.anthropic);
|
|
139
|
+
for (const credential of credentials) {
|
|
140
|
+
if (credential.type !== "oauth" || !credential.access) continue;
|
|
141
|
+
if (credential.expires > now + expiryBuffer) {
|
|
134
142
|
return {
|
|
135
|
-
apiKey:
|
|
143
|
+
apiKey: credential.access,
|
|
136
144
|
baseUrl: DEFAULT_BASE_URL,
|
|
137
145
|
isOAuth: true,
|
|
138
146
|
};
|
|
@@ -90,14 +90,18 @@ export interface ModelsJson {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/** auth.json structure for OAuth credentials */
|
|
93
|
+
export interface AnthropicOAuthCredential {
|
|
94
|
+
type: "oauth";
|
|
95
|
+
access: string;
|
|
96
|
+
refresh?: string;
|
|
97
|
+
/** Expiry timestamp in milliseconds */
|
|
98
|
+
expires: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type AnthropicAuthJsonEntry = AnthropicOAuthCredential | AnthropicOAuthCredential[];
|
|
102
|
+
|
|
93
103
|
export interface AuthJson {
|
|
94
|
-
anthropic?:
|
|
95
|
-
type: "oauth";
|
|
96
|
-
access: string;
|
|
97
|
-
refresh?: string;
|
|
98
|
-
/** Expiry timestamp in milliseconds */
|
|
99
|
-
expires: number;
|
|
100
|
-
};
|
|
104
|
+
anthropic?: AnthropicAuthJsonEntry;
|
|
101
105
|
}
|
|
102
106
|
|
|
103
107
|
/** Anthropic API response types */
|
|
@@ -70,8 +70,7 @@ export class OAuthSelectorComponent extends Container {
|
|
|
70
70
|
const isAvailable = provider.available;
|
|
71
71
|
|
|
72
72
|
// Check if user is logged in for this provider
|
|
73
|
-
const
|
|
74
|
-
const isLoggedIn = credentials?.type === "oauth";
|
|
73
|
+
const isLoggedIn = this.authStorage.hasOAuth(provider.id);
|
|
75
74
|
const statusIndicator = isLoggedIn ? theme.fg("success", ` ${theme.status.success} logged in`) : "";
|
|
76
75
|
|
|
77
76
|
let line = "";
|