@oh-my-pi/pi-coding-agent 8.12.1 → 8.12.2

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 CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [8.12.2] - 2026-01-28
6
+
7
+ ### Changed
8
+ - Replaced ripgrep-based file listing with fs.glob for project scans and find/read tooling
9
+
5
10
  ## [8.11.14] - 2026-01-28
6
11
 
7
12
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "8.12.1",
3
+ "version": "8.12.2",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -83,11 +83,11 @@
83
83
  "test": "bun test"
84
84
  },
85
85
  "dependencies": {
86
- "@oh-my-pi/omp-stats": "8.12.1",
87
- "@oh-my-pi/pi-agent-core": "8.12.1",
88
- "@oh-my-pi/pi-ai": "8.12.1",
89
- "@oh-my-pi/pi-tui": "8.12.1",
90
- "@oh-my-pi/pi-utils": "8.12.1",
86
+ "@oh-my-pi/omp-stats": "8.12.2",
87
+ "@oh-my-pi/pi-agent-core": "8.12.2",
88
+ "@oh-my-pi/pi-ai": "8.12.2",
89
+ "@oh-my-pi/pi-tui": "8.12.2",
90
+ "@oh-my-pi/pi-utils": "8.12.2",
91
91
  "@openai/agents": "^0.4.4",
92
92
  "@sinclair/typebox": "^0.34.48",
93
93
  "ajv": "^8.17.1",
@@ -47,6 +47,8 @@ export interface ExtensionUIDialogOptions {
47
47
  timeout?: number;
48
48
  /** Initial cursor position for select dialogs (0-indexed) */
49
49
  initialIndex?: number;
50
+ /** Render an outlined list for select dialogs */
51
+ outline?: boolean;
50
52
  }
51
53
 
52
54
  /**
@@ -2,7 +2,7 @@
2
2
  * Generic selector component for hooks.
3
3
  * Displays a list of string options with keyboard navigation.
4
4
  */
5
- import { Container, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
5
+ import { Container, matchesKey, Spacer, Text, type TUI, visibleWidth } from "@oh-my-pi/pi-tui";
6
6
  import { theme } from "../../modes/theme/theme";
7
7
  import { CountdownTimer } from "./countdown-timer";
8
8
  import { DynamicBorder } from "./dynamic-border";
@@ -11,12 +11,36 @@ export interface HookSelectorOptions {
11
11
  tui?: TUI;
12
12
  timeout?: number;
13
13
  initialIndex?: number;
14
+ outline?: boolean;
15
+ maxVisible?: number;
16
+ }
17
+
18
+ class OutlinedList extends Container {
19
+ private lines: string[] = [];
20
+
21
+ setLines(lines: string[]): void {
22
+ this.lines = lines;
23
+ this.invalidate();
24
+ }
25
+
26
+ render(width: number): string[] {
27
+ const borderColor = (text: string) => theme.fg("border", text);
28
+ const horizontal = borderColor(theme.boxSharp.horizontal.repeat(Math.max(1, width)));
29
+ const innerWidth = Math.max(1, width - 2);
30
+ const content = this.lines.map(line => {
31
+ const pad = Math.max(0, innerWidth - visibleWidth(line));
32
+ return `${borderColor(theme.boxSharp.vertical)}${line}${" ".repeat(pad)}${borderColor(theme.boxSharp.vertical)}`;
33
+ });
34
+ return [horizontal, ...content, horizontal];
35
+ }
14
36
  }
15
37
 
16
38
  export class HookSelectorComponent extends Container {
17
39
  private options: string[];
18
40
  private selectedIndex: number;
19
- private listContainer: Container;
41
+ private maxVisible: number;
42
+ private listContainer: Container | undefined;
43
+ private outlinedList: OutlinedList | undefined;
20
44
  private onSelectCallback: (option: string) => void;
21
45
  private onCancelCallback: () => void;
22
46
  private titleText: Text;
@@ -34,6 +58,7 @@ export class HookSelectorComponent extends Container {
34
58
 
35
59
  this.options = options;
36
60
  this.selectedIndex = Math.min(opts?.initialIndex ?? 0, options.length - 1);
61
+ this.maxVisible = Math.max(3, opts?.maxVisible ?? 12);
37
62
  this.onSelectCallback = onSelect;
38
63
  this.onCancelCallback = onCancel;
39
64
  this.baseTitle = title;
@@ -62,8 +87,13 @@ export class HookSelectorComponent extends Container {
62
87
  );
63
88
  }
64
89
 
65
- this.listContainer = new Container();
66
- this.addChild(this.listContainer);
90
+ if (opts?.outline) {
91
+ this.outlinedList = new OutlinedList();
92
+ this.addChild(this.outlinedList);
93
+ } else {
94
+ this.listContainer = new Container();
95
+ this.addChild(this.listContainer);
96
+ }
67
97
  this.addChild(new Spacer(1));
68
98
  this.addChild(new Text(theme.fg("dim", "up/down navigate enter select esc cancel"), 1, 0));
69
99
  this.addChild(new Spacer(1));
@@ -73,13 +103,31 @@ export class HookSelectorComponent extends Container {
73
103
  }
74
104
 
75
105
  private updateList(): void {
76
- this.listContainer.clear();
77
- for (let i = 0; i < this.options.length; i++) {
106
+ const lines: string[] = [];
107
+ const startIndex = Math.max(
108
+ 0,
109
+ Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.options.length - this.maxVisible),
110
+ );
111
+ const endIndex = Math.min(startIndex + this.maxVisible, this.options.length);
112
+
113
+ for (let i = startIndex; i < endIndex; i++) {
78
114
  const isSelected = i === this.selectedIndex;
79
115
  const text = isSelected
80
116
  ? theme.fg("accent", `${theme.nav.cursor} `) + theme.fg("accent", this.options[i])
81
117
  : ` ${theme.fg("text", this.options[i])}`;
82
- this.listContainer.addChild(new Text(text, 1, 0));
118
+ lines.push(text);
119
+ }
120
+
121
+ if (startIndex > 0 || endIndex < this.options.length) {
122
+ lines.push(theme.fg("dim", ` (${this.selectedIndex + 1}/${this.options.length})`));
123
+ }
124
+ if (this.outlinedList) {
125
+ this.outlinedList.setLines(lines);
126
+ return;
127
+ }
128
+ this.listContainer?.clear();
129
+ for (const line of lines) {
130
+ this.listContainer?.addChild(new Text(line, 1, 0));
83
131
  }
84
132
  }
85
133
 
@@ -1,4 +1,4 @@
1
- import type { Component, TUI } from "@oh-my-pi/pi-tui";
1
+ import type { Component, OverlayHandle, TUI } from "@oh-my-pi/pi-tui";
2
2
  import { Spacer, Text } from "@oh-my-pi/pi-tui";
3
3
  import { logger } from "@oh-my-pi/pi-utils";
4
4
  import { KeybindingsManager } from "../../config/keybindings";
@@ -18,6 +18,17 @@ import type { InteractiveModeContext } from "../../modes/types";
18
18
  import { setTerminalTitle } from "../../utils/title-generator";
19
19
 
20
20
  export class ExtensionUiController {
21
+ private hookSelectorOverlay: OverlayHandle | undefined;
22
+ private hookInputOverlay: OverlayHandle | undefined;
23
+
24
+ private readonly dialogOverlayOptions = {
25
+ anchor: "bottom-center",
26
+ width: "80%",
27
+ minWidth: 40,
28
+ maxHeight: "70%",
29
+ margin: 1,
30
+ } as const;
31
+
21
32
  constructor(private ctx: InteractiveModeContext) {}
22
33
 
23
34
  /**
@@ -491,6 +502,9 @@ export class ExtensionUiController {
491
502
  dialogOptions?: ExtensionUIDialogOptions,
492
503
  ): Promise<string | undefined> {
493
504
  const { promise, resolve } = Promise.withResolvers<string | undefined>();
505
+ this.hookSelectorOverlay?.hide();
506
+ this.hookSelectorOverlay = undefined;
507
+ const maxVisible = Math.max(4, Math.min(15, this.ctx.ui.terminal.rows - 12));
494
508
  this.ctx.hookSelector = new HookSelectorComponent(
495
509
  title,
496
510
  options,
@@ -502,13 +516,15 @@ export class ExtensionUiController {
502
516
  this.hideHookSelector();
503
517
  resolve(undefined);
504
518
  },
505
- { initialIndex: dialogOptions?.initialIndex, timeout: dialogOptions?.timeout, tui: this.ctx.ui },
519
+ {
520
+ initialIndex: dialogOptions?.initialIndex,
521
+ timeout: dialogOptions?.timeout,
522
+ tui: this.ctx.ui,
523
+ outline: dialogOptions?.outline,
524
+ maxVisible,
525
+ },
506
526
  );
507
-
508
- this.ctx.editorContainer.clear();
509
- this.ctx.editorContainer.addChild(this.ctx.hookSelector);
510
- this.ctx.ui.setFocus(this.ctx.hookSelector);
511
- this.ctx.ui.requestRender();
527
+ this.hookSelectorOverlay = this.ctx.ui.showOverlay(this.ctx.hookSelector, this.dialogOverlayOptions);
512
528
  return promise;
513
529
  }
514
530
 
@@ -516,8 +532,9 @@ export class ExtensionUiController {
516
532
  * Hide the hook selector.
517
533
  */
518
534
  hideHookSelector(): void {
519
- this.ctx.editorContainer.clear();
520
- this.ctx.editorContainer.addChild(this.ctx.editor);
535
+ this.ctx.hookSelector?.dispose();
536
+ this.hookSelectorOverlay?.hide();
537
+ this.hookSelectorOverlay = undefined;
521
538
  this.ctx.hookSelector = undefined;
522
539
  this.ctx.ui.setFocus(this.ctx.editor);
523
540
  this.ctx.ui.requestRender();
@@ -536,6 +553,8 @@ export class ExtensionUiController {
536
553
  */
537
554
  showHookInput(title: string, placeholder?: string): Promise<string | undefined> {
538
555
  const { promise, resolve } = Promise.withResolvers<string | undefined>();
556
+ this.hookInputOverlay?.hide();
557
+ this.hookInputOverlay = undefined;
539
558
  this.ctx.hookInput = new HookInputComponent(
540
559
  title,
541
560
  placeholder,
@@ -548,11 +567,7 @@ export class ExtensionUiController {
548
567
  resolve(undefined);
549
568
  },
550
569
  );
551
-
552
- this.ctx.editorContainer.clear();
553
- this.ctx.editorContainer.addChild(this.ctx.hookInput);
554
- this.ctx.ui.setFocus(this.ctx.hookInput);
555
- this.ctx.ui.requestRender();
570
+ this.hookInputOverlay = this.ctx.ui.showOverlay(this.ctx.hookInput, this.dialogOverlayOptions);
556
571
  return promise;
557
572
  }
558
573
 
@@ -560,8 +575,9 @@ export class ExtensionUiController {
560
575
  * Hide the hook input.
561
576
  */
562
577
  hideHookInput(): void {
563
- this.ctx.editorContainer.clear();
564
- this.ctx.editorContainer.addChild(this.ctx.editor);
578
+ this.ctx.hookInput?.dispose();
579
+ this.hookInputOverlay?.hide();
580
+ this.hookInputOverlay = undefined;
565
581
  this.ctx.hookInput = undefined;
566
582
  this.ctx.ui.setFocus(this.ctx.editor);
567
583
  this.ctx.ui.requestRender();
@@ -85,4 +85,4 @@ Read `rule://<name>` when working in their domain.
85
85
  {{/if}}
86
86
 
87
87
  Current date and time: {{dateTime}}
88
- Current working directory: {{cwd}}
88
+ Current working directory: {{cwd}}
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * System prompt construction and project context loading
3
3
  */
4
- import type * as fsTypes from "node:fs";
5
4
  import * as fs from "node:fs/promises";
6
5
  import * as os from "node:os";
7
6
  import * as path from "node:path";
7
+ import { globPaths } from "@oh-my-pi/pi-utils";
8
8
  import { $ } from "bun";
9
9
  import chalk from "chalk";
10
10
  import { contextFileCapability } from "./capability/context-file";
@@ -16,8 +16,6 @@ import { loadSkills, type Skill } from "./extensibility/skills";
16
16
  import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" };
17
17
  import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
18
18
  import type { ToolName } from "./tools";
19
- import { runRg } from "./tools/grep";
20
- import { ensureTool } from "./utils/tools-manager";
21
19
 
22
20
  interface GitContext {
23
21
  isRepo: boolean;
@@ -200,24 +198,20 @@ type ProjectTreeScan = {
200
198
  truncatedDirs: Set<string>;
201
199
  };
202
200
 
203
- const RG_TIMEOUT_MS = 5000;
201
+ const GLOB_TIMEOUT_MS = 5000;
204
202
 
205
203
  /**
206
- * Scan project tree using ripgrep to respect gitignore.
207
- * Returns null if ripgrep is unavailable.
204
+ * Scan project tree using fs.promises.glob with exclusion filters.
205
+ * Returns null if glob fails.
208
206
  */
209
- async function scanProjectTreeWithRg(root: string): Promise<ProjectTreeScan | null> {
210
- const rgPath = await ensureTool("rg", { silent: true });
211
- if (!rgPath) return null;
212
-
213
- const args = ["--files", "--no-require-git", "--color=never", root];
214
-
215
- let stdout: string;
207
+ async function scanProjectTreeWithGlob(root: string): Promise<ProjectTreeScan | null> {
208
+ let entries: string[];
216
209
  try {
217
- const signal = AbortSignal.timeout(RG_TIMEOUT_MS);
218
- const result = await runRg(rgPath, args, signal);
219
- if (result.exitCode !== 0 && result.exitCode !== 1) return null;
220
- stdout = result.stdout;
210
+ entries = await globPaths("**/*", {
211
+ cwd: root,
212
+ gitignore: true,
213
+ timeoutMs: GLOB_TIMEOUT_MS,
214
+ });
221
215
  } catch {
222
216
  return null;
223
217
  }
@@ -227,19 +221,19 @@ async function scanProjectTreeWithRg(root: string): Promise<ProjectTreeScan | nu
227
221
  const dirContents = new Map<string, Map<string, boolean>>();
228
222
  dirContents.set(root, new Map());
229
223
 
230
- for (const line of stdout.split("\n")) {
231
- const filePath = line.trim();
224
+ for (const entry of entries) {
225
+ const filePath = entry.trim();
232
226
  if (!filePath) continue;
233
-
227
+ const absolutePath = path.join(root, filePath);
234
228
  // Check static ignores on path components
235
- const relative = path.relative(root, filePath);
229
+ const relative = path.relative(root, absolutePath);
236
230
  const parts = relative.split(path.sep);
237
231
  if (parts.some(p => PROJECT_TREE_IGNORED.has(p))) continue;
238
232
 
239
233
  // Add file to its parent directory
240
- const parent = path.dirname(filePath);
234
+ const parent = path.dirname(absolutePath);
241
235
  if (!dirContents.has(parent)) dirContents.set(parent, new Map());
242
- dirContents.get(parent)!.set(filePath, false);
236
+ dirContents.get(parent)!.set(absolutePath, false);
243
237
 
244
238
  // Add all intermediate directories
245
239
  let dir = parent;
@@ -317,82 +311,10 @@ async function scanProjectTreeWithRg(root: string): Promise<ProjectTreeScan | nu
317
311
  return { children, truncated, truncatedDirs };
318
312
  }
319
313
 
320
- /**
321
- * Fallback scan using readdir when ripgrep is unavailable.
322
- */
323
- async function scanProjectTreeFallback(root: string): Promise<ProjectTreeScan> {
324
- const children = new Map<string, ProjectTreeEntry[]>();
325
- let entryCount = 0;
326
- let truncated = false;
327
- const truncatedDirs = new Set<string>();
328
-
329
- const queue: Array<{ dirPath: string; depth: number }> = [{ dirPath: root, depth: 0 }];
330
- let cursor = 0;
331
-
332
- while (cursor < queue.length && !truncated) {
333
- const { dirPath, depth } = queue[cursor];
334
- cursor += 1;
335
- let entries: fsTypes.Dirent[];
336
- try {
337
- entries = await fs.readdir(dirPath, { withFileTypes: true });
338
- } catch {
339
- continue;
340
- }
341
-
342
- const filtered = entries.filter(entry => !PROJECT_TREE_IGNORED.has(entry.name));
343
- const withStats = await Promise.all(
344
- filtered.map(async entry => {
345
- const entryPath = path.join(dirPath, entry.name);
346
- try {
347
- const stats = await fs.stat(entryPath);
348
- return { entry, entryPath, mtimeMs: stats.mtimeMs };
349
- } catch {
350
- return { entry, entryPath, mtimeMs: 0 };
351
- }
352
- }),
353
- );
354
-
355
- withStats.sort((a, b) => {
356
- if (a.mtimeMs !== b.mtimeMs) return b.mtimeMs - a.mtimeMs;
357
- return a.entry.name.localeCompare(b.entry.name);
358
- });
359
-
360
- const perDirLimit = depth >= PROJECT_TREE_PER_DIR_DEPTH ? PROJECT_TREE_PER_DIR_LIMIT : null;
361
- const limited = perDirLimit === null ? withStats : withStats.slice(0, perDirLimit);
362
- const hasMoreEntries = perDirLimit !== null && withStats.length > perDirLimit;
363
-
364
- const mapped: ProjectTreeEntry[] = [];
365
- for (const entryWithStat of limited) {
366
- if (entryCount >= PROJECT_TREE_LIMIT) {
367
- truncated = true;
368
- break;
369
- }
370
-
371
- mapped.push({
372
- name: entryWithStat.entry.name,
373
- isDirectory: entryWithStat.entry.isDirectory(),
374
- path: entryWithStat.entryPath,
375
- });
376
- entryCount += 1;
377
-
378
- if (entryWithStat.entry.isDirectory()) {
379
- queue.push({ dirPath: entryWithStat.entryPath, depth: depth + 1 });
380
- }
381
- }
382
-
383
- if (!truncated && hasMoreEntries) {
384
- truncatedDirs.add(dirPath);
385
- }
386
- children.set(dirPath, mapped);
387
- }
388
-
389
- return { children, truncated, truncatedDirs };
390
- }
391
-
392
314
  async function scanProjectTree(root: string): Promise<ProjectTreeScan> {
393
- const rgResult = await scanProjectTreeWithRg(root);
394
- if (rgResult) return rgResult;
395
- return scanProjectTreeFallback(root);
315
+ const globResult = await scanProjectTreeWithGlob(root);
316
+ if (globResult) return globResult;
317
+ return { children: new Map(), truncated: false, truncatedDirs: new Set() };
396
318
  }
397
319
 
398
320
  function renderProjectTree(scan: ProjectTreeScan, root: string): string {
package/src/tools/ask.ts CHANGED
@@ -77,6 +77,7 @@ export interface AskToolDetails {
77
77
 
78
78
  const OTHER_OPTION = "Other (type your own)";
79
79
  const RECOMMENDED_SUFFIX = " (Recommended)";
80
+ const ASK_TIMEOUT_MS = 30000;
80
81
 
81
82
  function getDoneOptionLabel(): string {
82
83
  return `${theme.status.success} Done selecting`;
@@ -113,7 +114,7 @@ interface UIContext {
113
114
  select(
114
115
  prompt: string,
115
116
  options: string[],
116
- options_?: { initialIndex?: number; timeout?: number },
117
+ options_?: { initialIndex?: number; timeout?: number; outline?: boolean },
117
118
  ): Promise<string | undefined>;
118
119
  input(prompt: string): Promise<string | undefined>;
119
120
  }
@@ -131,7 +132,7 @@ async function askSingleQuestion(
131
132
 
132
133
  if (multi) {
133
134
  const selected = new Set<string>();
134
- let cursorIndex = 0;
135
+ let cursorIndex = Math.min(Math.max(recommended ?? 0, 0), optionLabels.length - 1);
135
136
 
136
137
  while (true) {
137
138
  const opts: string[] = [];
@@ -148,13 +149,22 @@ async function askSingleQuestion(
148
149
  opts.push(OTHER_OPTION);
149
150
 
150
151
  const prefix = selected.size > 0 ? `(${selected.size} selected) ` : "";
151
- const choice = await ui.select(`${prefix}${question}`, opts, { initialIndex: cursorIndex });
152
+ const selectionStart = Date.now();
153
+ const choice = await ui.select(`${prefix}${question}`, opts, {
154
+ initialIndex: cursorIndex,
155
+ timeout: ASK_TIMEOUT_MS,
156
+ outline: true,
157
+ });
158
+ const elapsed = Date.now() - selectionStart;
159
+ const timedOut = elapsed >= ASK_TIMEOUT_MS;
152
160
 
153
161
  if (choice === undefined || choice === doneLabel) break;
154
162
 
155
163
  if (choice === OTHER_OPTION) {
156
- const input = await ui.input("Enter your response:");
157
- if (input) customInput = input;
164
+ if (!timedOut) {
165
+ const input = await ui.input("Enter your response:");
166
+ if (input) customInput = input;
167
+ }
158
168
  break;
159
169
  }
160
170
 
@@ -179,13 +189,18 @@ async function askSingleQuestion(
179
189
  selected.add(opt);
180
190
  }
181
191
  }
192
+
193
+ if (timedOut) {
194
+ break;
195
+ }
182
196
  }
183
197
  selectedOptions = Array.from(selected);
184
198
  } else {
185
199
  const displayLabels = addRecommendedSuffix(optionLabels, recommended);
186
200
  const choice = await ui.select(question, [...displayLabels, OTHER_OPTION], {
187
- timeout: 30000,
201
+ timeout: ASK_TIMEOUT_MS,
188
202
  initialIndex: recommended,
203
+ outline: true,
189
204
  });
190
205
  if (choice === OTHER_OPTION) {
191
206
  const input = await ui.input("Enter your response:");
package/src/tools/find.ts CHANGED
@@ -3,7 +3,7 @@ import * as path from "node:path";
3
3
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
4
4
  import type { Component } from "@oh-my-pi/pi-tui";
5
5
  import { Text } from "@oh-my-pi/pi-tui";
6
- import { isEnoent, untilAborted } from "@oh-my-pi/pi-utils";
6
+ import { globPaths, isEnoent, untilAborted } from "@oh-my-pi/pi-utils";
7
7
  import type { Static } from "@sinclair/typebox";
8
8
  import { Type } from "@sinclair/typebox";
9
9
  import { renderPromptTemplate } from "../config/prompt-templates";
@@ -11,14 +11,12 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
11
  import type { Theme } from "../modes/theme/theme";
12
12
  import findDescription from "../prompts/tools/find.md" with { type: "text" };
13
13
  import { renderFileList, renderStatusLine, renderTreeList } from "../tui";
14
- import { ensureTool } from "../utils/tools-manager";
15
14
  import type { ToolSession } from ".";
16
- import { runRg } from "./grep";
17
15
  import { applyListLimit } from "./list-limit";
18
16
  import type { OutputMeta } from "./output-meta";
19
17
  import { resolveToCwd } from "./path-utils";
20
18
  import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils";
21
- import { ToolError, throwIfAborted } from "./tool-errors";
19
+ import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
22
20
  import { toolResult } from "./tool-result";
23
21
  import { type TruncationResult, truncateHead } from "./truncate";
24
22
 
@@ -30,7 +28,7 @@ const findSchema = Type.Object({
30
28
  });
31
29
 
32
30
  const DEFAULT_LIMIT = 1000;
33
- const RG_TIMEOUT_MS = 5000;
31
+ const GLOB_TIMEOUT_MS = 5000;
34
32
 
35
33
  export interface FindToolDetails {
36
34
  truncation?: TruncationResult;
@@ -80,7 +78,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
80
78
  params: Static<typeof findSchema>,
81
79
  signal?: AbortSignal,
82
80
  _onUpdate?: AgentToolUpdateCallback<FindToolDetails>,
83
- context?: AgentToolContext,
81
+ _context?: AgentToolContext,
84
82
  ): Promise<AgentToolResult<FindToolDetails>> {
85
83
  const { pattern, path: searchDir, limit, hidden } = params;
86
84
 
@@ -107,7 +105,6 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
107
105
  }
108
106
  const includeHidden = hidden ?? true;
109
107
  const globPattern = normalizedPattern.replace(/\\/g, "/");
110
- const globMatcher = new Bun.Glob(globPattern);
111
108
 
112
109
  // If custom operations provided with glob, use that instead of fd
113
110
  if (this.customOps?.glob) {
@@ -171,44 +168,30 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
171
168
  throw new ToolError(`Path is not a directory: ${searchPath}`);
172
169
  }
173
170
 
174
- // Default: use rg
175
- const rgPath = await ensureTool("rg", {
176
- silent: true,
177
- notify: message => context?.ui?.notify(message, "info"),
178
- });
179
- if (!rgPath) {
180
- throw new ToolError("rg is not available and could not be downloaded");
171
+ let lines: string[];
172
+ try {
173
+ lines = await globPaths(globPattern, {
174
+ cwd: searchPath,
175
+ gitignore: true,
176
+ dot: includeHidden,
177
+ signal,
178
+ timeoutMs: GLOB_TIMEOUT_MS,
179
+ });
180
+ } catch (error) {
181
+ if (error instanceof Error && error.name === "AbortError") {
182
+ throw new ToolAbortError();
183
+ }
184
+ if (error instanceof Error && error.name === "TimeoutError") {
185
+ const timeoutSeconds = Math.max(1, Math.round(GLOB_TIMEOUT_MS / 1000));
186
+ throw new ToolError(`glob timed out after ${timeoutSeconds}s`);
187
+ }
188
+ throw error;
181
189
  }
182
190
 
183
- const args = [
184
- "--files",
185
- ...(includeHidden ? ["--hidden"] : []),
186
- "--no-require-git",
187
- "--color=never",
188
- "--glob",
189
- "!**/.git/**",
190
- "--glob",
191
- "!**/node_modules/**",
192
- searchPath,
193
- ];
194
-
195
- // Run rg with timeout
196
- const mainTimeoutSignal = AbortSignal.timeout(RG_TIMEOUT_MS);
197
- const mainCombinedSignal = signal ? AbortSignal.any([signal, mainTimeoutSignal]) : mainTimeoutSignal;
198
- const { stdout, stderr, exitCode } = await runRg(rgPath, args, mainCombinedSignal);
199
- const output = stdout.trim();
200
-
201
- // rg exit codes: 0 = found files, 1 = no matches, other = error
202
- // Treat exit code 1 with no output as "no files found"
203
- if (!output) {
204
- if (exitCode !== 0 && exitCode !== 1) {
205
- throw new ToolError(stderr.trim() || `rg failed (exit ${exitCode})`);
206
- }
191
+ if (lines.length === 0) {
207
192
  const details: FindToolDetails = { scopePath, fileCount: 0, files: [], truncated: false };
208
193
  return toolResult(details).text("No files found matching pattern").done();
209
194
  }
210
-
211
- const lines = output.split("\n");
212
195
  const relativized: string[] = [];
213
196
  const mtimes: number[] = [];
214
197
 
@@ -220,16 +203,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
220
203
  }
221
204
 
222
205
  const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\");
223
- let relativePath = line;
224
- if (line.startsWith(searchPath)) {
225
- relativePath = line.slice(searchPath.length + 1); // +1 for the /
226
- } else {
227
- relativePath = path.relative(searchPath, line);
228
- }
229
- const matchPath = relativePath.replace(/\\/g, "/");
230
- if (!globMatcher.match(matchPath)) {
231
- continue;
232
- }
206
+ let relativePath = line.replace(/\\/g, "/");
233
207
 
234
208
  let mtimeMs = 0;
235
209
  let isDirectory = false;
package/src/tools/grep.ts CHANGED
@@ -73,13 +73,22 @@ export interface RgResult {
73
73
  *
74
74
  * @throws ToolAbortError if signal is aborted
75
75
  */
76
- export async function runRg(rgPath: string, args: string[], signal?: AbortSignal): Promise<RgResult> {
77
- const child = ptree.cspawn([rgPath, ...args], { signal });
76
+ export async function runRg(
77
+ rgPath: string,
78
+ args: string[],
79
+ options?: { signal?: AbortSignal; timeoutMs?: number },
80
+ ): Promise<RgResult> {
81
+ const child = ptree.cspawn([rgPath, ...args], { signal: options?.signal, timeout: options?.timeoutMs });
82
+ const timeoutSeconds = options?.timeoutMs ? Math.max(1, Math.round(options.timeoutMs / 1000)) : undefined;
83
+ const timeoutMessage = timeoutSeconds ? `rg timed out after ${timeoutSeconds}s` : "rg timed out";
78
84
 
79
85
  let stdout: string;
80
86
  try {
81
87
  stdout = await child.nothrow().text();
82
88
  } catch (err) {
89
+ if (err instanceof ptree.TimeoutError) {
90
+ throw new ToolError(timeoutMessage);
91
+ }
83
92
  if (err instanceof ptree.Exception && err.aborted) {
84
93
  throw new ToolAbortError();
85
94
  }
@@ -91,6 +100,9 @@ export async function runRg(rgPath: string, args: string[], signal?: AbortSignal
91
100
  await child.exited;
92
101
  } catch (err) {
93
102
  exitError = err;
103
+ if (err instanceof ptree.TimeoutError) {
104
+ throw new ToolError(timeoutMessage);
105
+ }
94
106
  if (err instanceof ptree.Exception && err.aborted) {
95
107
  throw new ToolAbortError();
96
108
  }
package/src/tools/read.ts CHANGED
@@ -4,7 +4,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb
4
4
  import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
5
5
  import type { Component } from "@oh-my-pi/pi-tui";
6
6
  import { Text } from "@oh-my-pi/pi-tui";
7
- import { ptree } from "@oh-my-pi/pi-utils";
7
+ import { globPaths, ptree } from "@oh-my-pi/pi-utils";
8
8
  import { Type } from "@sinclair/typebox";
9
9
  import { CONFIG_DIR_NAME } from "../config";
10
10
  import { renderPromptTemplate } from "../config/prompt-templates";
@@ -16,7 +16,6 @@ import { renderCodeCell, renderOutputBlock, renderStatusLine } from "../tui";
16
16
  import { formatDimensionNote, resizeImage } from "../utils/image-resize";
17
17
  import { detectSupportedImageMimeTypeFromFile } from "../utils/mime";
18
18
  import { ensureTool } from "../utils/tools-manager";
19
- import { runRg } from "./grep";
20
19
  import { applyListLimit } from "./list-limit";
21
20
  import { LsTool } from "./ls";
22
21
  import type { OutputMeta } from "./output-meta";
@@ -49,6 +48,7 @@ const MAX_FUZZY_RESULTS = 5;
49
48
  const MAX_FUZZY_CANDIDATES = 20000;
50
49
  const MIN_BASE_SIMILARITY = 0.5;
51
50
  const MIN_FULL_SIMILARITY = 0.6;
51
+ const GLOB_TIMEOUT_MS = 5000;
52
52
 
53
53
  function normalizePathForMatch(value: string): string {
54
54
  return value
@@ -162,95 +162,32 @@ function similarityScore(a: string, b: string): number {
162
162
  async function listCandidateFiles(
163
163
  searchRoot: string,
164
164
  signal?: AbortSignal,
165
- notify?: (message: string) => void,
165
+ _notify?: (message: string) => void,
166
166
  ): Promise<{ files: string[]; truncated: boolean; error?: string }> {
167
- let rgPath: string | undefined;
167
+ let files: string[];
168
168
  try {
169
- rgPath = await ensureTool("rg", { silent: true, notify });
170
- } catch {
171
- return { files: [], truncated: false, error: "rg not available" };
172
- }
173
-
174
- if (!rgPath) {
175
- return { files: [], truncated: false, error: "rg not available" };
176
- }
177
-
178
- const args: string[] = [
179
- "--files",
180
- "--color=never",
181
- "--hidden",
182
- "--glob",
183
- "!**/.git/**",
184
- "--glob",
185
- "!**/node_modules/**",
186
- ];
187
-
188
- const gitignoreFiles = new Set<string>();
189
- const rootGitignore = path.join(searchRoot, ".gitignore");
190
- if (await Bun.file(rootGitignore).exists()) {
191
- gitignoreFiles.add(rootGitignore);
192
- }
193
-
194
- try {
195
- const gitignoreArgs = [
196
- "--files",
197
- "--color=never",
198
- "--hidden",
199
- "--no-ignore",
200
- "--glob",
201
- "!**/.git/**",
202
- "--glob",
203
- "!**/node_modules/**",
204
- "--glob",
205
- ".gitignore",
206
- searchRoot,
207
- ];
208
- const { stdout } = await runRg(rgPath, gitignoreArgs, signal);
209
- const output = stdout.trim();
210
- if (output) {
211
- const nestedGitignores = output
212
- .split("\n")
213
- .map(line => line.replace(/\r$/, "").trim())
214
- .filter(line => line.length > 0);
215
- for (const file of nestedGitignores) {
216
- const normalized = file.replace(/\\/g, "/");
217
- if (normalized.includes("/node_modules/") || normalized.includes("/.git/")) {
218
- continue;
219
- }
220
- gitignoreFiles.add(file);
221
- }
222
- }
169
+ files = await globPaths("**/*", {
170
+ cwd: searchRoot,
171
+ gitignore: true,
172
+ dot: true,
173
+ signal,
174
+ timeoutMs: GLOB_TIMEOUT_MS,
175
+ });
223
176
  } catch (error) {
224
- if (error instanceof ToolAbortError) {
225
- throw error;
177
+ if (error instanceof Error && error.name === "AbortError") {
178
+ throw new ToolAbortError();
226
179
  }
227
- // Ignore gitignore scan errors.
228
- }
229
-
230
- for (const gitignorePath of gitignoreFiles) {
231
- args.push("--ignore-file", gitignorePath);
232
- }
233
-
234
- args.push(searchRoot);
235
-
236
- const { stdout, stderr, exitCode } = await runRg(rgPath, args, signal);
237
- const output = stdout.trim();
238
-
239
- if (!output) {
240
- // rg exit codes: 0 = ok, 1 = no matches, other = error
241
- if (exitCode !== 0 && exitCode !== 1) {
242
- return { files: [], truncated: false, error: stderr.trim() || `rg failed (exit ${exitCode})` };
180
+ if (error instanceof Error && error.name === "TimeoutError") {
181
+ const timeoutSeconds = Math.max(1, Math.round(GLOB_TIMEOUT_MS / 1000));
182
+ return { files: [], truncated: false, error: `glob timed out after ${timeoutSeconds}s` };
243
183
  }
244
- return { files: [], truncated: false };
184
+ const message = error instanceof Error ? error.message : String(error);
185
+ return { files: [], truncated: false, error: message };
245
186
  }
246
187
 
247
- const files = output
248
- .split("\n")
249
- .map(line => line.replace(/\r$/, "").trim())
250
- .filter(line => line.length > 0);
251
-
252
- const truncated = files.length > MAX_FUZZY_CANDIDATES;
253
- const limited = truncated ? files.slice(0, MAX_FUZZY_CANDIDATES) : files;
188
+ const normalizedFiles = files.map(line => line.replace(/\r$/, "").trim()).filter(line => line.length > 0);
189
+ const truncated = normalizedFiles.length > MAX_FUZZY_CANDIDATES;
190
+ const limited = truncated ? normalizedFiles.slice(0, MAX_FUZZY_CANDIDATES) : normalizedFiles;
254
191
 
255
192
  return { files: limited, truncated };
256
193
  }
@@ -304,11 +241,7 @@ async function findReadPathSuggestions(
304
241
  const cleaned = file.replace(/\r$/, "").trim();
305
242
  if (!cleaned) continue;
306
243
 
307
- const relativePath = path.isAbsolute(cleaned)
308
- ? cleaned.startsWith(searchRoot)
309
- ? cleaned.slice(searchRoot.length + 1)
310
- : path.relative(searchRoot, cleaned)
311
- : cleaned;
244
+ const relativePath = cleaned;
312
245
 
313
246
  if (!relativePath || relativePath.startsWith("..")) {
314
247
  continue;