@oh-my-pi/pi-coding-agent 13.6.2 → 13.7.1

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/src/tools/ask.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
19
19
  import type { Component } from "@oh-my-pi/pi-tui";
20
20
  import { TERMINAL, Text } from "@oh-my-pi/pi-tui";
21
- import { ptree, untilAborted } from "@oh-my-pi/pi-utils";
21
+ import { untilAborted } from "@oh-my-pi/pi-utils";
22
22
  import { type Static, Type } from "@sinclair/typebox";
23
23
  import { renderPromptTemplate } from "../config/prompt-templates";
24
24
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
@@ -95,6 +95,14 @@ function addRecommendedSuffix(labels: string[], recommendedIndex?: number): stri
95
95
  });
96
96
  }
97
97
 
98
+ function getAutoSelectionOnTimeout(optionLabels: string[], recommended?: number): string[] {
99
+ if (optionLabels.length === 0) return [];
100
+ if (typeof recommended === "number" && recommended >= 0 && recommended < optionLabels.length) {
101
+ return [optionLabels[recommended]];
102
+ }
103
+ return [optionLabels[0]];
104
+ }
105
+
98
106
  /** Strip "(Recommended)" suffix from a label */
99
107
  function stripRecommendedSuffix(label: string): string {
100
108
  return label.endsWith(RECOMMENDED_SUFFIX) ? label.slice(0, -RECOMMENDED_SUFFIX.length) : label;
@@ -107,15 +115,43 @@ function stripRecommendedSuffix(label: string): string {
107
115
  interface SelectionResult {
108
116
  selectedOptions: string[];
109
117
  customInput?: string;
118
+ timedOut: boolean;
119
+ navigation?: "back" | "forward";
120
+ cancelled?: boolean;
121
+ }
122
+
123
+ interface NavigationControls {
124
+ allowBack: boolean;
125
+ allowForward: boolean;
126
+ progressText?: string;
127
+ }
128
+ interface AskSingleQuestionOptions {
129
+ recommended?: number;
130
+ timeout?: number;
131
+ signal?: AbortSignal;
132
+ initialSelection?: Pick<SelectionResult, "selectedOptions" | "customInput">;
133
+ navigation?: NavigationControls;
110
134
  }
111
135
 
112
136
  interface UIContext {
113
137
  select(
114
138
  prompt: string,
115
139
  options: string[],
116
- options_?: { initialIndex?: number; signal?: AbortSignal; outline?: boolean },
140
+ options_?: {
141
+ initialIndex?: number;
142
+ timeout?: number;
143
+ signal?: AbortSignal;
144
+ outline?: boolean;
145
+ onTimeout?: () => void;
146
+ onLeft?: () => void;
147
+ onRight?: () => void;
148
+ helpText?: string;
149
+ },
150
+ ): Promise<string | undefined>;
151
+ input(
152
+ prompt: string,
153
+ options_?: { signal?: AbortSignal; timeout?: number; onTimeout?: () => void },
117
154
  ): Promise<string | undefined>;
118
- input(prompt: string, options_?: { signal?: AbortSignal }): Promise<string | undefined>;
119
155
  }
120
156
 
121
157
  async function askSingleQuestion(
@@ -123,17 +159,75 @@ async function askSingleQuestion(
123
159
  question: string,
124
160
  optionLabels: string[],
125
161
  multi: boolean,
126
- recommended?: number,
127
- signal?: AbortSignal,
162
+ options: AskSingleQuestionOptions = {},
128
163
  ): Promise<SelectionResult> {
164
+ const { recommended, timeout, signal, initialSelection, navigation } = options;
129
165
  const doneLabel = getDoneOptionLabel();
130
- let selectedOptions: string[] = [];
131
- let customInput: string | undefined;
166
+ let selectedOptions = [...(initialSelection?.selectedOptions ?? [])];
167
+ let customInput = initialSelection?.customInput;
168
+ let timedOut = false;
132
169
 
133
- if (multi) {
134
- const selected = new Set<string>();
135
- let cursorIndex = Math.min(Math.max(recommended ?? 0, 0), optionLabels.length - 1);
170
+ const selectOption = async (
171
+ prompt: string,
172
+ optionsToShow: string[],
173
+ initialIndex?: number,
174
+ ): Promise<{ choice: string | undefined; timedOut: boolean; navigation?: "back" | "forward" }> => {
175
+ let timeoutTriggered = false;
176
+ const onTimeout = () => {
177
+ timeoutTriggered = true;
178
+ };
179
+ let navigationAction: "back" | "forward" | undefined;
180
+ const helpText = navigation
181
+ ? "up/down navigate enter select ←/→ question esc cancel"
182
+ : "up/down navigate enter select esc cancel";
183
+ const dialogOptions = {
184
+ initialIndex,
185
+ timeout,
186
+ signal,
187
+ outline: true,
188
+ onTimeout,
189
+ helpText,
190
+ onLeft: navigation?.allowBack
191
+ ? () => {
192
+ navigationAction = "back";
193
+ }
194
+ : undefined,
195
+ onRight: navigation?.allowForward
196
+ ? () => {
197
+ navigationAction = "forward";
198
+ }
199
+ : undefined,
200
+ };
201
+ const startMs = Date.now();
202
+ const choice = signal
203
+ ? await untilAborted(signal, () => ui.select(prompt, optionsToShow, dialogOptions))
204
+ : await ui.select(prompt, optionsToShow, dialogOptions);
205
+ if (!timeoutTriggered && choice === undefined && typeof timeout === "number") {
206
+ timeoutTriggered = Date.now() - startMs >= timeout;
207
+ }
208
+ return { choice, timedOut: timeoutTriggered, navigation: navigationAction };
209
+ };
210
+
211
+ const promptForInput = async (): Promise<{ input: string | undefined; timedOut: boolean }> => {
212
+ let inputTimedOut = false;
213
+ const onTimeout = () => {
214
+ inputTimedOut = true;
215
+ };
216
+ const input = signal
217
+ ? await untilAborted(signal, () => ui.input("Enter your response:", { signal, timeout, onTimeout }))
218
+ : await ui.input("Enter your response:", { signal, timeout, onTimeout });
219
+ return { input, timedOut: inputTimedOut };
220
+ };
136
221
 
222
+ const promptWithProgress = navigation?.progressText ? `${question} (${navigation.progressText})` : question;
223
+ if (multi) {
224
+ const selected = new Set<string>(selectedOptions);
225
+ let cursorIndex = Math.min(Math.max(recommended ?? 0, 0), Math.max(optionLabels.length - 1, 0));
226
+ const firstSelected = selectedOptions[0];
227
+ if (firstSelected) {
228
+ const selectedIndex = optionLabels.indexOf(firstSelected);
229
+ if (selectedIndex >= 0) cursorIndex = selectedIndex;
230
+ }
137
231
  while (true) {
138
232
  const opts: string[] = [];
139
233
 
@@ -142,38 +236,41 @@ async function askSingleQuestion(
142
236
  opts.push(`${checkbox} ${opt}`);
143
237
  }
144
238
 
145
- // Done after options, before Other - so cursor stays on options after toggle
146
- if (selected.size > 0) {
239
+ if (!navigation?.allowForward && selected.size > 0) {
147
240
  opts.push(doneLabel);
148
241
  }
149
242
  opts.push(OTHER_OPTION);
150
243
 
151
244
  const prefix = selected.size > 0 ? `(${selected.size} selected) ` : "";
152
- const choice = signal
153
- ? await untilAborted(signal, () =>
154
- ui.select(`${prefix}${question}`, opts, {
155
- initialIndex: cursorIndex,
156
- signal,
157
- outline: true,
158
- }),
159
- )
160
- : await ui.select(`${prefix}${question}`, opts, {
161
- initialIndex: cursorIndex,
162
- signal,
163
- outline: true,
164
- });
165
-
166
- if (choice === undefined || choice === doneLabel) break;
245
+ const {
246
+ choice,
247
+ timedOut: selectTimedOut,
248
+ navigation: arrowNavigation,
249
+ } = await selectOption(`${prefix}${promptWithProgress}`, opts, cursorIndex);
250
+
251
+ if (arrowNavigation) {
252
+ return { selectedOptions: Array.from(selected), customInput, timedOut, navigation: arrowNavigation };
253
+ }
254
+ if (choice === undefined) {
255
+ if (selectTimedOut) {
256
+ timedOut = true;
257
+ break;
258
+ }
259
+ return { selectedOptions: Array.from(selected), customInput, timedOut, cancelled: true };
260
+ }
261
+ if (choice === doneLabel) break;
167
262
 
168
263
  if (choice === OTHER_OPTION) {
169
- const input = signal
170
- ? await untilAborted(signal, () => ui.input("Enter your response:", { signal }))
171
- : await ui.input("Enter your response:", { signal });
172
- if (input) customInput = input;
264
+ if (selectTimedOut) {
265
+ timedOut = true;
266
+ break;
267
+ }
268
+ const inputResult = await promptForInput();
269
+ if (inputResult.input) customInput = inputResult.input;
270
+ if (inputResult.timedOut) timedOut = true;
173
271
  break;
174
272
  }
175
273
 
176
- // Find which index was selected and update cursor position
177
274
  const selectedIdx = opts.indexOf(choice);
178
275
  if (selectedIdx >= 0) {
179
276
  cursorIndex = selectedIdx;
@@ -194,35 +291,65 @@ async function askSingleQuestion(
194
291
  selected.add(opt);
195
292
  }
196
293
  }
294
+
295
+ if (selectTimedOut) {
296
+ timedOut = true;
297
+ break;
298
+ }
197
299
  }
198
300
  selectedOptions = Array.from(selected);
199
301
  } else {
200
302
  const displayLabels = addRecommendedSuffix(optionLabels, recommended);
201
- const choice = signal
202
- ? await untilAborted(signal, () =>
203
- ui.select(question, [...displayLabels, OTHER_OPTION], {
204
- initialIndex: recommended,
205
- signal,
206
- outline: true,
207
- }),
208
- )
209
- : await ui.select(question, [...displayLabels, OTHER_OPTION], {
210
- initialIndex: recommended,
211
- signal,
212
- outline: true,
213
- });
214
-
215
- if (choice === OTHER_OPTION) {
216
- const input = signal
217
- ? await untilAborted(signal, () => ui.input("Enter your response:", { signal }))
218
- : await ui.input("Enter your response:", { signal });
219
- if (input) customInput = input;
220
- } else if (choice) {
303
+ const optionsWithNavigation = [...displayLabels, OTHER_OPTION];
304
+
305
+ let initialIndex = recommended;
306
+ const previouslySelected = selectedOptions[0];
307
+ if (previouslySelected) {
308
+ const selectedIndex = optionLabels.indexOf(previouslySelected);
309
+ if (selectedIndex >= 0) initialIndex = selectedIndex;
310
+ } else if (customInput) {
311
+ initialIndex = displayLabels.length;
312
+ }
313
+ if (initialIndex !== undefined) {
314
+ const maxIndex = Math.max(optionsWithNavigation.length - 1, 0);
315
+ initialIndex = Math.max(0, Math.min(initialIndex, maxIndex));
316
+ }
317
+
318
+ const {
319
+ choice,
320
+ timedOut: selectTimedOut,
321
+ navigation: arrowNavigation,
322
+ } = await selectOption(promptWithProgress, optionsWithNavigation, initialIndex);
323
+ timedOut = selectTimedOut;
324
+
325
+ if (arrowNavigation) {
326
+ return { selectedOptions, customInput, timedOut, navigation: arrowNavigation };
327
+ }
328
+ if (choice === undefined) {
329
+ if (!timedOut) {
330
+ return { selectedOptions, customInput, timedOut, cancelled: true };
331
+ }
332
+ } else if (choice === OTHER_OPTION) {
333
+ if (!selectTimedOut) {
334
+ const inputResult = await promptForInput();
335
+ if (inputResult.input) customInput = inputResult.input;
336
+ if (inputResult.timedOut) timedOut = true;
337
+ }
338
+ selectedOptions = [];
339
+ } else {
221
340
  selectedOptions = [stripRecommendedSuffix(choice)];
341
+ customInput = undefined;
342
+ }
343
+ if (navigation?.allowForward) {
344
+ return { selectedOptions, customInput, timedOut, navigation: "forward" };
222
345
  }
223
346
  }
224
347
 
225
- return { selectedOptions, customInput };
348
+ if (timedOut && selectedOptions.length === 0 && !customInput) {
349
+ selectedOptions = getAutoSelectionOnTimeout(optionLabels, recommended);
350
+ }
351
+
352
+ return { selectedOptions, customInput, timedOut };
226
353
  }
227
354
 
228
355
  function formatQuestionResult(result: QuestionResult): string {
@@ -309,28 +436,29 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
309
436
  };
310
437
  }
311
438
 
312
- const askQuestion = async (q: AskParams["questions"][number]) => {
439
+ const askQuestion = async (
440
+ q: AskParams["questions"][number],
441
+ options?: { previous?: QuestionResult; navigation?: NavigationControls },
442
+ ) => {
313
443
  const optionLabels = q.options.map(o => o.label);
314
- const timeoutSignal = timeout == null ? undefined : AbortSignal.timeout(timeout);
315
- const questionSignal = ptree.combineSignals(signal, timeoutSignal);
316
444
  try {
317
- const { selectedOptions, customInput } = await askSingleQuestion(
445
+ const { selectedOptions, customInput, navigation, cancelled, timedOut } = await askSingleQuestion(
318
446
  ui,
319
447
  q.question,
320
448
  optionLabels,
321
449
  q.multi ?? false,
322
- q.recommended,
323
- questionSignal,
450
+ {
451
+ recommended: q.recommended,
452
+ timeout: timeout ?? undefined,
453
+ signal,
454
+ initialSelection: options?.previous,
455
+ navigation: options?.navigation,
456
+ },
324
457
  );
325
- return { optionLabels, selectedOptions, customInput, timedOut: false };
458
+ return { optionLabels, selectedOptions, customInput, navigation, cancelled, timedOut };
326
459
  } catch (error) {
327
460
  if (error instanceof Error && error.name === "AbortError") {
328
- if (signal?.aborted) {
329
- throw new ToolAbortError("Ask input was cancelled");
330
- }
331
- if (timeoutSignal?.aborted) {
332
- return { optionLabels, selectedOptions: [], customInput: undefined, timedOut: true };
333
- }
461
+ throw new ToolAbortError("Ask input was cancelled");
334
462
  }
335
463
  throw error;
336
464
  }
@@ -338,9 +466,9 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
338
466
 
339
467
  if (params.questions.length === 1) {
340
468
  const [q] = params.questions;
341
- const { optionLabels, selectedOptions, customInput, timedOut } = await askQuestion(q);
469
+ const { optionLabels, selectedOptions, customInput, cancelled, timedOut } = await askQuestion(q);
342
470
 
343
- if (!timedOut && selectedOptions.length === 0 && !customInput) {
471
+ if (!timedOut && (cancelled || (selectedOptions.length === 0 && !customInput))) {
344
472
  context.abort();
345
473
  throw new ToolAbortError("Ask tool was cancelled by the user");
346
474
  }
@@ -366,25 +494,59 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
366
494
  return { content: [{ type: "text" as const, text: responseText }], details };
367
495
  }
368
496
 
369
- const results: QuestionResult[] = [];
370
-
371
- for (const q of params.questions) {
372
- const { optionLabels, selectedOptions, customInput, timedOut } = await askQuestion(q);
497
+ const resultsByIndex: Array<QuestionResult | undefined> = Array.from({ length: params.questions.length });
498
+ let questionIndex = 0;
499
+ while (questionIndex < params.questions.length) {
500
+ const q = params.questions[questionIndex]!;
501
+ const previous = resultsByIndex[questionIndex];
502
+ const navigation: NavigationControls = {
503
+ allowBack: questionIndex > 0,
504
+ allowForward: true,
505
+ progressText: `${questionIndex + 1}/${params.questions.length}`,
506
+ };
507
+ const {
508
+ optionLabels,
509
+ selectedOptions,
510
+ customInput,
511
+ navigation: navAction,
512
+ cancelled,
513
+ timedOut,
514
+ } = await askQuestion(q, { previous, navigation });
373
515
 
374
- if (!timedOut && selectedOptions.length === 0 && !customInput) {
516
+ if (cancelled && !timedOut) {
375
517
  context.abort();
376
518
  throw new ToolAbortError("Ask tool was cancelled by the user");
377
519
  }
378
- results.push({
520
+
521
+ resultsByIndex[questionIndex] = {
379
522
  id: q.id,
380
523
  question: q.question,
381
524
  options: optionLabels,
382
525
  multi: q.multi ?? false,
383
526
  selectedOptions,
384
527
  customInput,
385
- });
528
+ };
529
+
530
+ if (navAction === "back") {
531
+ questionIndex = Math.max(0, questionIndex - 1);
532
+ continue;
533
+ }
534
+
535
+ questionIndex += 1;
386
536
  }
387
537
 
538
+ const results = resultsByIndex.map((result, index) => {
539
+ if (result) return result;
540
+ const q = params.questions[index]!;
541
+ return {
542
+ id: q.id,
543
+ question: q.question,
544
+ options: q.options.map(o => o.label),
545
+ multi: q.multi ?? false,
546
+ selectedOptions: [],
547
+ };
548
+ });
549
+
388
550
  const details: AskToolDetails = { results };
389
551
  const responseLines = results.map(formatQuestionResult);
390
552
  const responseText = `User answers:\n${responseLines.join("\n")}`;
package/src/tools/grep.ts CHANGED
@@ -30,6 +30,7 @@ const grepSchema = Type.Object({
30
30
  pre: Type.Optional(Type.Number({ description: "Lines of context before matches" })),
31
31
  post: Type.Optional(Type.Number({ description: "Lines of context after matches" })),
32
32
  multiline: Type.Optional(Type.Boolean({ description: "Enable multiline matching" })),
33
+ gitignore: Type.Optional(Type.Boolean({ description: "Respect .gitignore files during search (default: true)" })),
33
34
  limit: Type.Optional(Type.Number({ description: "Limit output to first N matches (default: 20)" })),
34
35
  offset: Type.Optional(Type.Number({ description: "Skip first N entries before applying limit (default: 0)" })),
35
36
  });
@@ -77,7 +78,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
77
78
  _onUpdate?: AgentToolUpdateCallback<GrepToolDetails>,
78
79
  _toolContext?: AgentToolContext,
79
80
  ): Promise<AgentToolResult<GrepToolDetails>> {
80
- const { pattern, path: searchDir, glob, type, i, pre, post, multiline, limit, offset } = params;
81
+ const { pattern, path: searchDir, glob, type, i, gitignore, pre, post, multiline, limit, offset } = params;
81
82
 
82
83
  return untilAborted(signal, async () => {
83
84
  const normalizedPattern = pattern.trim();
@@ -101,6 +102,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
101
102
  const normalizedContextBefore = pre ?? defaultContextBefore;
102
103
  const normalizedContextAfter = post ?? defaultContextAfter;
103
104
  const ignoreCase = i ?? false;
105
+ const useGitignore = gitignore ?? true;
104
106
  const patternHasNewline = normalizedPattern.includes("\n") || normalizedPattern.includes("\\n");
105
107
  const effectiveMultiline = multiline ?? patternHasNewline;
106
108
 
@@ -162,6 +164,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
162
164
  ignoreCase,
163
165
  multiline: effectiveMultiline,
164
166
  hidden: true,
167
+ gitignore: useGitignore,
165
168
  cache: false,
166
169
  maxCount: internalLimit,
167
170
  offset: normalizedOffset > 0 ? normalizedOffset : undefined,
@@ -368,6 +371,7 @@ interface GrepRenderArgs {
368
371
  glob?: string;
369
372
  type?: string;
370
373
  i?: boolean;
374
+ gitignore?: boolean;
371
375
  pre?: number;
372
376
  post?: number;
373
377
  multiline?: boolean;
@@ -385,6 +389,7 @@ export const grepToolRenderer = {
385
389
  if (args.glob) meta.push(`glob:${args.glob}`);
386
390
  if (args.type) meta.push(`type:${args.type}`);
387
391
  if (args.i) meta.push("case:insensitive");
392
+ if (args.gitignore === false) meta.push("gitignore:false");
388
393
  if (args.pre !== undefined && args.pre > 0) {
389
394
  meta.push(`pre:${args.pre}`);
390
395
  }