@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 +5 -0
- package/package.json +6 -6
- package/src/extensibility/extensions/types.ts +2 -0
- package/src/modes/components/hook-selector.ts +55 -7
- package/src/modes/controllers/extension-ui-controller.ts +32 -16
- package/src/prompts/system/custom-system-prompt.md +1 -1
- package/src/system-prompt.ts +20 -98
- package/src/tools/ask.ts +21 -6
- package/src/tools/find.ts +24 -50
- package/src/tools/grep.ts +14 -2
- package/src/tools/read.ts +22 -89
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
3
|
-
"version": "8.12.
|
|
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.
|
|
87
|
-
"@oh-my-pi/pi-agent-core": "8.12.
|
|
88
|
-
"@oh-my-pi/pi-ai": "8.12.
|
|
89
|
-
"@oh-my-pi/pi-tui": "8.12.
|
|
90
|
-
"@oh-my-pi/pi-utils": "8.12.
|
|
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",
|
|
@@ -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
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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.
|
|
520
|
-
this.
|
|
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.
|
|
564
|
-
this.
|
|
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();
|
package/src/system-prompt.ts
CHANGED
|
@@ -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
|
|
201
|
+
const GLOB_TIMEOUT_MS = 5000;
|
|
204
202
|
|
|
205
203
|
/**
|
|
206
|
-
* Scan project tree using
|
|
207
|
-
* Returns null if
|
|
204
|
+
* Scan project tree using fs.promises.glob with exclusion filters.
|
|
205
|
+
* Returns null if glob fails.
|
|
208
206
|
*/
|
|
209
|
-
async function
|
|
210
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
231
|
-
const filePath =
|
|
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,
|
|
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(
|
|
234
|
+
const parent = path.dirname(absolutePath);
|
|
241
235
|
if (!dirContents.has(parent)) dirContents.set(parent, new Map());
|
|
242
|
-
dirContents.get(parent)!.set(
|
|
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
|
|
394
|
-
if (
|
|
395
|
-
return
|
|
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
|
|
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
|
-
|
|
157
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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(
|
|
77
|
-
|
|
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
|
-
|
|
165
|
+
_notify?: (message: string) => void,
|
|
166
166
|
): Promise<{ files: string[]; truncated: boolean; error?: string }> {
|
|
167
|
-
let
|
|
167
|
+
let files: string[];
|
|
168
168
|
try {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
225
|
-
throw
|
|
177
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
178
|
+
throw new ToolAbortError();
|
|
226
179
|
}
|
|
227
|
-
|
|
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
|
-
|
|
184
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
185
|
+
return { files: [], truncated: false, error: message };
|
|
245
186
|
}
|
|
246
187
|
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
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 =
|
|
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;
|