# Conflicts:
#	packages/coding-agent/CHANGELOG.md
This commit is contained in:
Mario Zechner
2026-04-17 00:28:14 +02:00
80 changed files with 2747 additions and 883 deletions

View File

@@ -2,16 +2,75 @@
## [Unreleased]
### Fixed
- Fixed missing root exports for `RpcClient` and RPC protocol types from `@mariozechner/pi-coding-agent`, so ESM consumers can import them from the main package entrypoint ([#3275](https://github.com/badlogic/pi-mono/issues/3275))
## [0.67.6] - 2026-04-16
### New Features
- Prompt templates support an `argument-hint` frontmatter field that renders before the description in the `/` autocomplete dropdown, using `<angle>` for required and `[square]` for optional arguments. See [docs/prompt-templates.md#argument-hints](docs/prompt-templates.md#argument-hints).
- New `after_provider_response` extension hook lets extensions inspect provider HTTP status codes and headers immediately after each response is received and before stream consumption begins. See [docs/extensions.md](docs/extensions.md).
- Compact interactive startup header with a comma-separated view of loaded AGENTS.md files, prompt templates, skills, and extensions. Press `Ctrl+O` to toggle the expanded listing.
- Markdown links in assistant output now render as OSC 8 hyperlinks on terminals that advertise support; unknown terminals and tmux/screen default to plain text so URLs are never silently dropped.
### Added
- Added `--no-context-files` (`-nc`) to disable `AGENTS.md` and `CLAUDE.md` context file discovery and loading ([#3253](https://github.com/badlogic/pi-mono/issues/3253))
- Added `argument-hint` frontmatter field for prompt templates, displayed before the description in the autocomplete dropdown ([#2780](https://github.com/badlogic/pi-mono/pull/2780) by [@andresvi94](https://github.com/andresvi94))
- Added `after_provider_response` extension hook so extensions can inspect provider HTTP status codes and headers after each provider response is received and before stream consumption begins ([#3128](https://github.com/badlogic/pi-mono/issues/3128))
- Added OSC 8 hyperlink rendering for markdown links when the terminal advertises support ([#3248](https://github.com/badlogic/pi-mono/pull/3248) by [@ofa1](https://github.com/ofa1))
### Changed
- Changed interactive startup header to a compact, comma-separated view of loaded AGENTS.md files, prompt templates, skills, and extensions, with `Ctrl+O` to toggle the expanded listing ([#3267](https://github.com/badlogic/pi-mono/pull/3267))
- Tightened hyperlink capability detection to default `hyperlinks: false` for unknown terminals and force it off under tmux/screen (including nested sessions), preventing markdown link URLs from disappearing on terminals that silently swallow OSC 8 sequences ([#3248](https://github.com/badlogic/pi-mono/pull/3248))
### Fixed
- Fixed interactive user message rendering to keep bottom padding visible in terminals affected by OSC 133 prompt markers without adding an extra blank line before the following assistant message ([#3090](https://github.com/badlogic/pi-mono/issues/3090))
- Fixed `--verbose` startup output to begin with expanded startup help and loaded resource listings after the compact startup header change ([#3147](https://github.com/badlogic/pi-mono/issues/3147))
- Fixed `find` tool returning no results for path-based glob patterns such as `src/**/*.spec.ts` or `some/parent/child/**` by switching fd into full-path mode and normalizing the pattern when it contains a `/` ([#3302](https://github.com/badlogic/pi-mono/issues/3302))
- Fixed `find` tool applying nested `.gitignore` rules across sibling directories (e.g. rules from `a/.gitignore` hiding matching files under `b/`) by dropping the manual `--ignore-file` collection and delegating to fd's hierarchical `.gitignore` handling via `--no-require-git` ([#3303](https://github.com/badlogic/pi-mono/issues/3303))
- Fixed OpenAI Responses prompt caching for non-`api.openai.com` base URLs (OpenAI-compatible proxies such as litellm, theclawbay) by sending the `session_id` and `x-client-request-id` cache-affinity headers unconditionally when a `sessionId` is provided, matching the official Codex CLI behavior ([#3264](https://github.com/badlogic/pi-mono/pull/3264) by [@vegarsti](https://github.com/vegarsti))
- Fixed the `preset` example extension to snapshot the active model, thinking level, and tool set on the first preset application and restore that state when cycling back to `(none)`, instead of falling back to a hardcoded default tool list ([#3272](https://github.com/badlogic/pi-mono/pull/3272) by [@stembi](https://github.com/stembi))
## [0.67.5] - 2026-04-16
### Fixed
- Fixed Opus 4.7 adaptive thinking configuration across Anthropic and Bedrock providers by recognizing Opus 4.7 adaptive-thinking support and mapping `xhigh` reasoning to provider-supported effort values ([#3286](https://github.com/badlogic/pi-mono/pull/3286) by [@markusylisiurunen](https://github.com/markusylisiurunen))
- Fixed Zellij `Shift+Enter` regressions by reverting the Zellij-specific Kitty keyboard query bypass and restoring the previous keyboard negotiation behavior ([#3259](https://github.com/badlogic/pi-mono/issues/3259))
## [0.67.4] - 2026-04-16
### New Features
- `--no-context-files` (`-nc`) disables automatic `AGENTS.md` / `CLAUDE.md` discovery when you need a clean run without project context injection. See [README.md#context-files](README.md#context-files).
- `loadProjectContextFiles()` is now exported as a standalone utility for extensions and SDK-style integrations that need to inspect the same context-file resolution order used by the CLI. See [README.md#context-files](README.md#context-files).
- New `after_provider_response` extension hook lets extensions inspect provider HTTP status codes and headers immediately after response creation and before stream consumption. See [docs/extensions.md](docs/extensions.md).
### Added
- Added `--no-context-files` (`-nc`) to disable `AGENTS.md` and `CLAUDE.md` context file discovery and loading ([#3253](https://github.com/badlogic/pi-mono/issues/3253))
- Exported `loadProjectContextFiles()` as a standalone utility so extensions can discover project context files without instantiating a full `DefaultResourceLoader` ([#3142](https://github.com/badlogic/pi-mono/issues/3142))
- Added `after_provider_response` extension hook so extensions can inspect provider HTTP status codes and headers after each provider response is received and before stream consumption begins ([#3128](https://github.com/badlogic/pi-mono/issues/3128))
### Changed
- Added `claude-opus-4-7` model for Anthropic.
- Changed Anthropic prompt caching to add a `cache_control` breakpoint on the last tool definition, so tool schemas can be cached independently from transcript updates while preserving existing cache retention behavior ([#3260](https://github.com/badlogic/pi-mono/issues/3260))
### Fixed
- Fixed markdown strikethrough parsing in interactive rendering and HTML export to require strict double-tilde delimiters (`~~text~~`) with non-whitespace boundaries.
- Fixed shutdown handling to kill tracked detached `bash` tool child processes on exit signals, preventing orphaned background processes.
>>>>>>> 165603189b60231ed8b88274729471cee676a0c0
- Fixed flaky `edit-tool-no-full-redraw` TUI tests by waiting for asynchronous preview and preflight error rendering instead of relying on fixed render ticks.
- Fixed `kimi-coding` default model selection to use `kimi-for-coding` instead of `kimi-k2-thinking` ([#3242](https://github.com/badlogic/pi-mono/issues/3242))
- Fixed `ctrl+z` on native Windows to avoid crashing interactive mode, disable the default suspend binding there, and show a status message when suspend is invoked manually ([#3191](https://github.com/badlogic/pi-mono/issues/3191))
- Fixed `find` tool cancellation and responsiveness on broad searches by making `.gitignore` discovery and `fd` execution fully abort-aware and non-blocking ([#3148](https://github.com/badlogic/pi-mono/issues/3148))
- Fixed `grep` broad-search stalls when `context=0` by formatting match lines from ripgrep JSON output instead of doing synchronous per-match file reads ([#3205](https://github.com/badlogic/pi-mono/issues/3205))
## [0.67.3] - 2026-04-15
@@ -31,7 +90,6 @@
- Fixed the `plan-mode` example extension to allow `eza` in the read-only bash allowlist instead of the deprecated `exa` command ([#3240](https://github.com/badlogic/pi-mono/pull/3240) by [@rwachtler](https://github.com/rwachtler))
- Fixed `google-vertex` API key resolution to treat `gcp-vertex-credentials` as an Application Default Credentials marker instead of a literal API key, so marker-based setups correctly fall back to ADC ([#3221](https://github.com/badlogic/pi-mono/pull/3221) by [@deepkilo](https://github.com/deepkilo))
- Fixed RPC `prompt` to wait for prompt preflight success before emitting its single authoritative response, while still treating handled and queued prompts as success ([#3049](https://github.com/badlogic/pi-mono/issues/3049))
- Fixed Alt keybindings inside Zellij by skipping the Kitty keyboard protocol query there and enabling xterm `modifyOtherKeys` mode 2 directly ([#3163](https://github.com/badlogic/pi-mono/issues/3163))
- Fixed `/scoped-models` reordering to propagate into the `/model` scoped tab, preserving the user-defined scoped model order instead of re-sorting it ([#3217](https://github.com/badlogic/pi-mono/issues/3217))
- Fixed `session_shutdown` to fire on `SIGHUP` and `SIGTERM` in interactive, print, and RPC modes so extensions can run shutdown cleanup on those signal-driven exits ([#3212](https://github.com/badlogic/pi-mono/issues/3212))
- Fixed screenshot path parsing to handle lower case am/pm in macOS screenshot filenames ([#3194](https://github.com/badlogic/pi-mono/pull/3194) by [@jay-aye-see-kay](https://github.com/jay-aye-see-kay))

View File

@@ -246,6 +246,7 @@ user sends prompt ────────────────────
│ ├─► turn_start │ │
│ ├─► context (can modify messages) │ │
│ ├─► before_provider_request (can inspect or replace payload)
│ ├─► after_provider_response (status + headers, before stream consume)
│ │ │ │
│ │ LLM responds, may call tools: │ │
│ │ ├─► tool_execution_start │ │
@@ -534,6 +535,22 @@ pi.on("before_provider_request", (event, ctx) => {
This is mainly useful for debugging provider serialization and cache behavior.
#### after_provider_response
Fired after an HTTP response is received and before its stream body is consumed. Handlers run in extension load order.
```typescript
pi.on("after_provider_response", (event, ctx) => {
// event.status - HTTP status code
// event.headers - normalized response headers
if (event.status === 429) {
console.log("rate limited", event.headers["retry-after"]);
}
});
```
Header availability depends on provider and transport. Providers that abstract HTTP responses may not expose headers.
### Model Events
#### model_select
@@ -2231,7 +2248,7 @@ All examples in [examples/extensions/](../examples/extensions/).
| `dirty-repo-guard.ts` | Warn on dirty git repo | `on("session_before_*")`, `exec` |
| `input-transform.ts` | Transform user input | `on("input")` |
| `model-status.ts` | React to model changes | `on("model_select")`, `setStatus` |
| `provider-payload.ts` | Inspect or patch provider payloads | `on("before_provider_request")` |
| `provider-payload.ts` | Inspect payloads and provider response headers | `on("before_provider_request")`, `on("after_provider_response")` |
| `system-prompt-header.ts` | Display system prompt info | `on("agent_start")`, `getSystemPrompt` |
| `claude-rules.ts` | Load rules from files | `on("session_start")`, `on("before_agent_start")` |
| `file-trigger.ts` | File watcher triggers messages | `sendMessage` |

View File

@@ -30,6 +30,27 @@ Review the staged changes (`git diff --cached`). Focus on:
- The filename becomes the command name. `review.md` becomes `/review`.
- `description` is optional. If missing, the first non-empty line is used.
- `argument-hint` is optional. When set, the hint is displayed before the description in the autocomplete dropdown.
### Argument Hints
Use `argument-hint` in frontmatter to show expected arguments in autocomplete. Use `<angle brackets>` for required arguments and `[square brackets]` for optional ones:
```markdown
---
description: Review PRs from URLs with structured issue and code analysis
argument-hint: "<PR-URL>"
---
```
This renders in the autocomplete dropdown as:
```
→ pr <PR-URL> — Review PRs from URLs with structured issue and code analysis
is <issue> — Analyze GitHub issues (bugs or feature requests)
wr [instructions] — Finish the current task end-to-end
cl — Audit changelog entries before release
```
## Usage

View File

@@ -32,12 +32,6 @@ If you want `Shift+Enter` to keep working in tmux via that remap, add `ctrl+j` t
}
```
## Zellij
Pi detects Zellij automatically and skips the Kitty keyboard protocol query there.
Zellij currently forwards that query to the outer terminal but still sends Alt as legacy ESC-prefixed sequences, which can break Alt keybindings if applications enable Kitty mode.
Pi uses xterm `modifyOtherKeys` mode 2 inside Zellij instead, so no extra Zellij config is required.
## WezTerm
Create `~/.wezterm.lua`:

View File

@@ -1,12 +1,12 @@
{
"name": "pi-extension-custom-provider",
"version": "1.18.3",
"version": "1.18.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-custom-provider",
"version": "1.18.3",
"version": "1.18.6",
"dependencies": {
"@anthropic-ai/sdk": "^0.52.0"
}

View File

@@ -1,7 +1,7 @@
{
"name": "pi-extension-custom-provider-anthropic",
"private": true,
"version": "1.18.3",
"version": "1.18.6",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",

View File

@@ -1,7 +1,7 @@
{
"name": "pi-extension-custom-provider-gitlab-duo",
"private": true,
"version": "1.18.3",
"version": "1.18.6",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",

View File

@@ -1,7 +1,7 @@
{
"name": "pi-extension-custom-provider-qwen-cli",
"private": true,
"version": "1.17.3",
"version": "1.17.6",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",

View File

@@ -40,6 +40,7 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type { Api, Model } from "@mariozechner/pi-ai";
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { DynamicBorder, getAgentDir } from "@mariozechner/pi-coding-agent";
import { Container, Key, type SelectItem, SelectList, Text } from "@mariozechner/pi-tui";
@@ -97,10 +98,17 @@ function loadPresets(cwd: string): PresetsConfig {
return { ...globalPresets, ...projectPresets };
}
interface OriginalState {
model: Model<Api> | undefined;
thinkingLevel: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
tools: string[];
}
export default function presetExtension(pi: ExtensionAPI) {
let presets: PresetsConfig = {};
let activePresetName: string | undefined;
let activePreset: Preset | undefined;
let originalState: OriginalState | undefined;
// Register --preset CLI flag
pi.registerFlag("preset", {
@@ -112,6 +120,15 @@ export default function presetExtension(pi: ExtensionAPI) {
* Apply a preset configuration.
*/
async function applyPreset(name: string, preset: Preset, ctx: ExtensionContext): Promise<boolean> {
// Snapshot state before the first preset is applied (i.e. only when transitioning from no-preset)
if (activePresetName === undefined) {
originalState = {
model: ctx.model,
thinkingLevel: pi.getThinkingLevel(),
tools: pi.getActiveTools(),
};
}
// Apply model if specified
if (preset.provider && preset.model) {
const model = ctx.modelRegistry.find(preset.provider, preset.model);
@@ -248,10 +265,18 @@ export default function presetExtension(pi: ExtensionAPI) {
if (!result) return;
if (result === "(none)") {
// Clear preset and restore defaults
// Clear preset and restore original state
activePresetName = undefined;
activePreset = undefined;
pi.setActiveTools(["read", "bash", "edit", "write"]);
if (originalState) {
if (originalState.model) {
await pi.setModel(originalState.model);
}
pi.setThinkingLevel(originalState.thinkingLevel);
pi.setActiveTools(originalState.tools);
} else {
pi.setActiveTools(["read", "bash", "edit", "write"]);
}
ctx.ui.notify("Preset cleared, defaults restored", "info");
updateStatus(ctx);
return;
@@ -296,7 +321,15 @@ export default function presetExtension(pi: ExtensionAPI) {
if (nextName === "(none)") {
activePresetName = undefined;
activePreset = undefined;
pi.setActiveTools(["read", "bash", "edit", "write"]);
if (originalState) {
if (originalState.model) {
await pi.setModel(originalState.model);
}
pi.setThinkingLevel(originalState.thinkingLevel);
pi.setActiveTools(originalState.tools);
} else {
pi.setActiveTools(["read", "bash", "edit", "write"]);
}
ctx.ui.notify("Preset cleared, defaults restored", "info");
updateStatus(ctx);
return;

View File

@@ -11,4 +11,8 @@ export default function (pi: ExtensionAPI) {
// Optional: replace the payload instead of only logging it.
// return { ...event.payload, temperature: 0 };
});
pi.on("after_provider_response", (event) => {
appendFileSync(logFile, `[${event.status}] ${JSON.stringify(event.headers)}\n\n`, "utf8");
});
}

View File

@@ -1,12 +1,12 @@
{
"name": "pi-extension-with-deps",
"version": "1.31.3",
"version": "1.31.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-with-deps",
"version": "1.31.3",
"version": "1.31.6",
"dependencies": {
"ms": "^2.1.3"
},

View File

@@ -1,7 +1,7 @@
{
"name": "pi-extension-with-deps",
"private": true,
"version": "1.31.3",
"version": "1.31.6",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",

View File

@@ -1,6 +1,6 @@
{
"name": "@mariozechner/pi-coding-agent",
"version": "0.67.3",
"version": "0.67.6",
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
"type": "module",
"piConfig": {
@@ -40,9 +40,9 @@
},
"dependencies": {
"@mariozechner/jiti": "^2.6.2",
"@mariozechner/pi-agent-core": "^0.67.3",
"@mariozechner/pi-ai": "^0.67.3",
"@mariozechner/pi-tui": "^0.67.3",
"@mariozechner/pi-agent-core": "^0.67.6",
"@mariozechner/pi-ai": "^0.67.6",
"@mariozechner/pi-tui": "^0.67.6",
"@silvia-odwyer/photon-node": "^0.3.4",
"ajv": "^8.17.1",
"chalk": "^5.5.0",

View File

@@ -33,6 +33,16 @@ export type CreateAgentSessionRuntimeFactory = (options: {
sessionStartEvent?: SessionStartEvent;
}) => Promise<CreateAgentSessionRuntimeResult>;
export class SessionImportFileNotFoundError extends Error {
readonly filePath: string;
constructor(filePath: string) {
super(`File not found: ${filePath}`);
this.name = "SessionImportFileNotFoundError";
this.filePath = filePath;
}
}
function extractUserMessageText(content: string | Array<{ type: string; text?: string }>): string {
if (typeof content === "string") {
return content;
@@ -251,7 +261,7 @@ export class AgentSessionRuntime {
async importFromJsonl(inputPath: string, cwdOverride?: string): Promise<{ cancelled: boolean }> {
const resolvedPath = resolve(inputPath);
if (!existsSync(resolvedPath)) {
throw new Error(`File not found: ${resolvedPath}`);
throw new SessionImportFileNotFoundError(resolvedPath);
}
const sessionDir = this.session.sessionManager.getSessionDir();

View File

@@ -1448,9 +1448,23 @@
}
// Configure marked with syntax highlighting and HTML escaping for text
const strictStrikethroughRegex = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/;
marked.use({
breaks: true,
gfm: true,
tokenizer: {
del(src) {
const match = strictStrikethroughRegex.exec(src);
if (!match) return undefined;
return {
type: 'del',
raw: match[0],
text: match[2],
tokens: this.lexer.inlineTokens(match[2])
};
}
},
renderer: {
// Code blocks: syntax highlight, no HTML escaping
code(token) {

View File

@@ -20,6 +20,7 @@ export type {
} from "./runner.js";
export { ExtensionRunner } from "./runner.js";
export type {
AfterProviderResponseEvent,
AgentEndEvent,
AgentStartEvent,
// Re-exports

View File

@@ -543,6 +543,13 @@ export interface BeforeProviderRequestEvent {
payload: unknown;
}
/** Fired after a provider response is received and before the response stream is consumed. */
export interface AfterProviderResponseEvent {
type: "after_provider_response";
status: number;
headers: Record<string, string>;
}
/** Fired after user submits prompt but before agent loop. */
export interface BeforeAgentStartEvent {
type: "before_agent_start";
@@ -863,6 +870,7 @@ export type ExtensionEvent =
| SessionEvent
| ContextEvent
| BeforeProviderRequestEvent
| AfterProviderResponseEvent
| BeforeAgentStartEvent
| AgentStartEvent
| AgentEndEvent
@@ -1010,6 +1018,7 @@ export interface ExtensionAPI {
event: "before_provider_request",
handler: ExtensionHandler<BeforeProviderRequestEvent, BeforeProviderRequestEventResult>,
): void;
on(event: "after_provider_response", handler: ExtensionHandler<AfterProviderResponseEvent>): void;
on(event: "before_agent_start", handler: ExtensionHandler<BeforeAgentStartEvent, BeforeAgentStartEventResult>): void;
on(event: "agent_start", handler: ExtensionHandler<AgentStartEvent>): void;
on(event: "agent_end", handler: ExtensionHandler<AgentEndEvent>): void;

View File

@@ -11,6 +11,7 @@ import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.js";
export interface PromptTemplate {
name: string;
description: string;
argumentHint?: string;
content: string;
sourceInfo: SourceInfo;
filePath: string; // Absolute path to the template file
@@ -121,6 +122,7 @@ function loadTemplateFromFile(filePath: string, sourceInfo: SourceInfo): PromptT
return {
name,
description,
...(frontmatter["argument-hint"] && { argumentHint: frontmatter["argument-hint"] }),
content: body,
sourceInfo,
filePath,

View File

@@ -73,7 +73,7 @@ function loadContextFileFromDir(dir: string): { path: string; content: string }
return null;
}
function loadProjectContextFiles(
export function loadProjectContextFiles(
options: { cwd?: string; agentDir?: string } = {},
): Array<{ path: string; content: string }> {
const resolvedCwd = options.cwd ?? process.cwd();

View File

@@ -314,6 +314,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
}
return runner.emitBeforeProviderRequest(payload);
},
onResponse: async (response, _model) => {
const runner = extensionRunnerRef.current;
if (!runner?.hasHandlers("after_provider_response")) {
return;
}
await runner.emit({
type: "after_provider_response",
status: response.status,
headers: response.headers,
});
},
sessionId: sessionManager.getSessionId(),
transformContext: async (messages) => {
const runner = extensionRunnerRef.current;

View File

@@ -1,9 +1,9 @@
import { createInterface } from "node:readline";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Text } from "@mariozechner/pi-tui";
import { type Static, Type } from "@sinclair/typebox";
import { spawnSync } from "child_process";
import { spawn } from "child_process";
import { existsSync } from "fs";
import { globSync } from "glob";
import path from "path";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
import { ensureTool } from "../../utils/tools-manager.js";
@@ -133,7 +133,19 @@ export function createFindToolDefinition(
return;
}
const onAbort = () => reject(new Error("Operation aborted"));
let settled = false;
let stopChild: (() => void) | undefined;
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
signal?.removeEventListener("abort", onAbort);
stopChild = undefined;
fn();
};
const onAbort = () => {
stopChild?.();
settle(() => reject(new Error("Operation aborted")));
};
signal?.addEventListener("abort", onAbort, { once: true });
(async () => {
@@ -145,19 +157,28 @@ export function createFindToolDefinition(
// If custom operations provide glob(), use that instead of fd.
if (customOps?.glob) {
if (!(await ops.exists(searchPath))) {
reject(new Error(`Path not found: ${searchPath}`));
settle(() => reject(new Error(`Path not found: ${searchPath}`)));
return;
}
if (signal?.aborted) {
settle(() => reject(new Error("Operation aborted")));
return;
}
const results = await ops.glob(pattern, searchPath, {
ignore: ["**/node_modules/**", "**/.git/**"],
limit: effectiveLimit,
});
signal?.removeEventListener("abort", onAbort);
if (signal?.aborted) {
settle(() => reject(new Error("Operation aborted")));
return;
}
if (results.length === 0) {
resolve({
content: [{ type: "text", text: "No files found matching pattern" }],
details: undefined,
});
settle(() =>
resolve({
content: [{ type: "text", text: "No files found matching pattern" }],
details: undefined,
}),
);
return;
}
@@ -183,111 +204,150 @@ export function createFindToolDefinition(
if (notices.length > 0) {
resultOutput += `\n\n[${notices.join(". ")}]`;
}
resolve({
content: [{ type: "text", text: resultOutput }],
details: Object.keys(details).length > 0 ? details : undefined,
});
settle(() =>
resolve({
content: [{ type: "text", text: resultOutput }],
details: Object.keys(details).length > 0 ? details : undefined,
}),
);
return;
}
// Default implementation uses fd.
const fdPath = await ensureTool("fd", true);
if (signal?.aborted) {
settle(() => reject(new Error("Operation aborted")));
return;
}
if (!fdPath) {
reject(new Error("fd is not available and could not be downloaded"));
settle(() => reject(new Error("fd is not available and could not be downloaded")));
return;
}
// Build fd arguments.
// Build fd arguments. --no-require-git makes fd apply hierarchical .gitignore
// semantics whether or not the search path is inside a git repository, without
// leaking sibling-directory rules the way --ignore-file (a global source) would.
const args: string[] = [
"--glob",
"--color=never",
"--hidden",
"--no-require-git",
"--max-results",
String(effectiveLimit),
];
// Include .gitignore files from the search tree.
const gitignoreFiles = new Set<string>();
const rootGitignore = path.join(searchPath, ".gitignore");
if (existsSync(rootGitignore)) gitignoreFiles.add(rootGitignore);
try {
const nestedGitignores = globSync("**/.gitignore", {
cwd: searchPath,
dot: true,
absolute: true,
ignore: ["**/node_modules/**", "**/.git/**"],
});
for (const file of nestedGitignores) gitignoreFiles.add(file);
} catch {
// ignore
}
for (const gitignorePath of gitignoreFiles) args.push("--ignore-file", gitignorePath);
args.push(pattern, searchPath);
const result = spawnSync(fdPath, args, { encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 });
signal?.removeEventListener("abort", onAbort);
if (result.error) {
reject(new Error(`Failed to run fd: ${result.error.message}`));
return;
// fd --glob matches against the basename unless --full-path is set; in --full-path
// mode it matches against the absolute candidate path, so a path-containing
// pattern like 'src/**/*.spec.ts' needs a leading '**/' to match anything.
let effectivePattern = pattern;
if (pattern.includes("/")) {
args.push("--full-path");
if (!pattern.startsWith("/") && !pattern.startsWith("**/") && pattern !== "**") {
effectivePattern = `**/${pattern}`;
}
}
args.push(effectivePattern, searchPath);
const output = result.stdout?.trim() || "";
if (result.status !== 0) {
const errorMsg = result.stderr?.trim() || `fd exited with code ${result.status}`;
if (!output) {
reject(new Error(errorMsg));
const child = spawn(fdPath, args, { stdio: ["ignore", "pipe", "pipe"] });
const rl = createInterface({ input: child.stdout });
let stderr = "";
const lines: string[] = [];
stopChild = () => {
if (!child.killed) {
child.kill();
}
};
const cleanup = () => {
rl.close();
};
child.stderr?.on("data", (chunk) => {
stderr += chunk.toString();
});
rl.on("line", (line) => {
lines.push(line);
});
child.on("error", (error) => {
cleanup();
settle(() => reject(new Error(`Failed to run fd: ${error.message}`)));
});
child.on("close", (code) => {
cleanup();
if (signal?.aborted) {
settle(() => reject(new Error("Operation aborted")));
return;
}
}
if (!output) {
resolve({
content: [{ type: "text", text: "No files found matching pattern" }],
details: undefined,
});
const output = lines.join("\n");
if (code !== 0) {
const errorMsg = stderr.trim() || `fd exited with code ${code}`;
if (!output) {
settle(() => reject(new Error(errorMsg)));
return;
}
}
if (!output) {
settle(() =>
resolve({
content: [{ type: "text", text: "No files found matching pattern" }],
details: undefined,
}),
);
return;
}
const relativized: string[] = [];
for (const rawLine of lines) {
const line = rawLine.replace(/\r$/, "").trim();
if (!line) continue;
const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\");
let relativePath = line;
if (line.startsWith(searchPath)) {
relativePath = line.slice(searchPath.length + 1);
} else {
relativePath = path.relative(searchPath, line);
}
if (hadTrailingSlash && !relativePath.endsWith("/")) relativePath += "/";
relativized.push(toPosixPath(relativePath));
}
const resultLimitReached = relativized.length >= effectiveLimit;
const rawOutput = relativized.join("\n");
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
let resultOutput = truncation.content;
const details: FindToolDetails = {};
const notices: string[] = [];
if (resultLimitReached) {
notices.push(
`${effectiveLimit} results limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
);
details.resultLimitReached = effectiveLimit;
}
if (truncation.truncated) {
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
details.truncation = truncation;
}
if (notices.length > 0) {
resultOutput += `\n\n[${notices.join(". ")}]`;
}
settle(() =>
resolve({
content: [{ type: "text", text: resultOutput }],
details: Object.keys(details).length > 0 ? details : undefined,
}),
);
});
} catch (e) {
if (signal?.aborted) {
settle(() => reject(new Error("Operation aborted")));
return;
}
const lines = output.split("\n");
const relativized: string[] = [];
for (const rawLine of lines) {
const line = rawLine.replace(/\r$/, "").trim();
if (!line) continue;
const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\");
let relativePath = line;
if (line.startsWith(searchPath)) {
relativePath = line.slice(searchPath.length + 1);
} else {
relativePath = path.relative(searchPath, line);
}
if (hadTrailingSlash && !relativePath.endsWith("/")) relativePath += "/";
relativized.push(toPosixPath(relativePath));
}
const resultLimitReached = relativized.length >= effectiveLimit;
const rawOutput = relativized.join("\n");
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
let resultOutput = truncation.content;
const details: FindToolDetails = {};
const notices: string[] = [];
if (resultLimitReached) {
notices.push(
`${effectiveLimit} results limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
);
details.resultLimitReached = effectiveLimit;
}
if (truncation.truncated) {
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
details.truncation = truncation;
}
if (notices.length > 0) {
resultOutput += `\n\n[${notices.join(". ")}]`;
}
resolve({
content: [{ type: "text", text: resultOutput }],
details: Object.keys(details).length > 0 ? details : undefined,
});
} catch (e: any) {
signal?.removeEventListener("abort", onAbort);
reject(e);
const error = e instanceof Error ? e : new Error(String(e));
settle(() => reject(error));
}
})();
});

View File

@@ -267,7 +267,7 @@ export function createGrepToolDefinition(
};
// Collect matches during streaming, then format them after rg exits.
const matches: Array<{ filePath: string; lineNumber: number }> = [];
const matches: Array<{ filePath: string; lineNumber: number; lineText?: string }> = [];
rl.on("line", (line) => {
if (!line.trim() || matchCount >= effectiveLimit) return;
let event: any;
@@ -280,7 +280,9 @@ export function createGrepToolDefinition(
matchCount++;
const filePath = event.data?.path?.text;
const lineNumber = event.data?.line_number;
if (filePath && typeof lineNumber === "number") matches.push({ filePath, lineNumber });
const lineText = event.data?.lines?.text;
if (filePath && typeof lineNumber === "number")
matches.push({ filePath, lineNumber, lineText });
if (matchCount >= effectiveLimit) {
matchLimitReached = true;
stopChild(true);
@@ -312,8 +314,19 @@ export function createGrepToolDefinition(
// Format matches after streaming finishes so custom readFile() backends can be async.
for (const match of matches) {
const block = await formatBlock(match.filePath, match.lineNumber);
outputLines.push(...block);
if (contextValue === 0 && match.lineText !== undefined) {
const relativePath = formatPath(match.filePath);
const sanitized = match.lineText
.replace(/\r\n/g, "\n")
.replace(/\r/g, "")
.replace(/\n$/, "");
const { text: truncatedText, wasTruncated } = truncateLine(sanitized);
if (wasTruncated) linesTruncated = true;
outputLines.push(`${relativePath}:${match.lineNumber}: ${truncatedText}`);
} else {
const block = await formatBlock(match.filePath, match.lineNumber);
outputLines.push(...block);
}
}
const rawOutput = outputLines.join("\n");

View File

@@ -153,7 +153,7 @@ export type {
} from "./core/package-manager.js";
export { DefaultPackageManager } from "./core/package-manager.js";
export type { ResourceCollision, ResourceDiagnostic, ResourceLoader } from "./core/resource-loader.js";
export { DefaultResourceLoader } from "./core/resource-loader.js";
export { DefaultResourceLoader, loadProjectContextFiles } from "./core/resource-loader.js";
// SDK for programmatic usage
export {
AgentSessionRuntime,
@@ -296,7 +296,14 @@ export { type MainOptions, main } from "./main.js";
export {
InteractiveMode,
type InteractiveModeOptions,
type ModelInfo,
type PrintModeOptions,
RpcClient,
type RpcClientOptions,
type RpcCommand,
type RpcEventListener,
type RpcResponse,
type RpcSessionState,
runPrintMode,
runRpcMode,
} from "./modes/index.js";

View File

@@ -47,7 +47,7 @@ import {
VERSION,
} from "../../config.js";
import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.js";
import type { AgentSessionRuntime } from "../../core/agent-session-runtime.js";
import { type AgentSessionRuntime, SessionImportFileNotFoundError } from "../../core/agent-session-runtime.js";
import type {
ExtensionContext,
ExtensionRunner,
@@ -58,7 +58,7 @@ import type {
import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.js";
import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.js";
import { createCompactionSummaryMessage } from "../../core/messages.js";
import { findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.js";
import { defaultModelPerProvider, findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.js";
import { DefaultPackageManager } from "../../core/package-manager.js";
import type { ResourceDiagnostic } from "../../core/resource-loader.js";
import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.js";
@@ -126,6 +126,22 @@ function isExpandable(obj: unknown): obj is Expandable {
return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
}
class ExpandableText extends Text implements Expandable {
constructor(
private readonly getCollapsedText: () => string,
private readonly getExpandedText: () => string,
expanded = false,
paddingX = 0,
paddingY = 0,
) {
super(expanded ? getExpandedText() : getCollapsedText(), paddingX, paddingY);
}
setExpanded(expanded: boolean): void {
this.setText(expanded ? this.getExpandedText() : this.getCollapsedText());
}
}
type CompactionQueuedMessage = {
text: string;
mode: "steer" | "followUp";
@@ -143,6 +159,14 @@ function isTruthyEnvFlag(value: string | undefined): boolean {
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
}
function isUnknownModel(model: Model<any> | undefined): boolean {
return !!model && model.provider === "unknown" && model.id === "unknown" && model.api === "unknown";
}
function hasDefaultModelProvider(providerId: string): providerId is keyof typeof defaultModelPerProvider {
return providerId in defaultModelPerProvider;
}
/**
* Options for InteractiveMode initialization.
*/
@@ -408,6 +432,7 @@ export class InteractiveMode {
const templateCommands: SlashCommand[] = this.session.promptTemplates.map((cmd) => ({
name: cmd.name,
description: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo),
...(cmd.argumentHint && { argumentHint: cmd.argumentHint }),
}));
// Convert extension commands to SlashCommand format
@@ -499,7 +524,7 @@ export class InteractiveMode {
// Build startup instructions using keybinding hint helpers
const hint = (keybinding: AppKeybinding, description: string) => keyHint(keybinding, description);
const instructions = [
const expandedInstructions = [
hint("app.interrupt", "to interrupt"),
hint("app.clear", "to clear"),
rawKeyHint(`${keyText("app.clear")} twice`, "to exit"),
@@ -520,11 +545,28 @@ export class InteractiveMode {
hint("app.clipboard.pasteImage", "to paste image"),
rawKeyHint("drop files", "to attach"),
].join("\n");
const compactInstructions = [
hint("app.interrupt", "interrupt"),
rawKeyHint(`${keyText("app.clear")}/${keyText("app.exit")}`, "clear/exit"),
rawKeyHint("/", "commands"),
rawKeyHint("!", "bash"),
hint("app.tools.expand", "more"),
].join(theme.fg("muted", " · "));
const compactOnboarding = theme.fg(
"dim",
`Press ${keyText("app.tools.expand")} to show full startup help and loaded resources.`,
);
const onboarding = theme.fg(
"dim",
`Pi can explain its own features and look up its docs. Ask it how to use or extend Pi.`,
);
this.builtInHeader = new Text(`${logo}\n${instructions}\n\n${onboarding}`, 1, 0);
this.builtInHeader = new ExpandableText(
() => `${logo}\n${compactInstructions}\n${compactOnboarding}\n\n${onboarding}`,
() => `${logo}\n${expandedInstructions}\n\n${onboarding}`,
this.getStartupExpansionState(),
1,
0,
);
// Setup UI layout
this.headerContainer.addChild(new Spacer(1));
@@ -836,6 +878,27 @@ export class InteractiveMode {
return result;
}
private formatContextPath(p: string): string {
const cwd = path.resolve(this.sessionManager.getCwd());
const absolutePath = path.isAbsolute(p) ? path.resolve(p) : path.resolve(cwd, p);
const relativePath = path.relative(cwd, absolutePath);
const isInsideCwd =
relativePath === "" ||
(!relativePath.startsWith("..") &&
!relativePath.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relativePath));
if (isInsideCwd) {
return relativePath || ".";
}
return this.formatDisplayPath(absolutePath);
}
private getStartupExpansionState(): boolean {
return this.options.verbose || this.toolOutputExpanded;
}
/**
* Get a short path relative to the package root for display.
*/
@@ -1057,6 +1120,38 @@ export class InteractiveMode {
}
const sectionHeader = (name: string, color: ThemeColor = "mdHeading") => theme.fg(color, `[${name}]`);
const compactPath = (resourcePath: string, sourceInfo?: SourceInfo): string => {
const shortPath = this.getShortPath(resourcePath, sourceInfo);
const normalizedPath = shortPath.replace(/\\/g, "/");
const segments = normalizedPath.split("/").filter((segment) => segment.length > 0 && segment !== "~");
if (segments.length > 0) {
return segments[segments.length - 1]!;
}
return shortPath;
};
const formatCompactList = (items: string[], options?: { sort?: boolean }): string => {
const labels = items.map((item) => item.trim()).filter((item) => item.length > 0);
if (options?.sort !== false) {
labels.sort((a, b) => a.localeCompare(b));
}
return theme.fg("dim", ` ${labels.join(", ")}`);
};
const addLoadedSection = (
name: string,
collapsedBody: string,
expandedBody = collapsedBody,
color: ThemeColor = "mdHeading",
): void => {
const section = new ExpandableText(
() => `${sectionHeader(name, color)}\n${collapsedBody}`,
() => `${sectionHeader(name, color)}\n${expandedBody}`,
this.getStartupExpansionState(),
0,
0,
);
this.chatContainer.addChild(section);
this.chatContainer.addChild(new Spacer(1));
};
const skillsResult = this.session.resourceLoader.getSkills();
const promptsResult = this.session.resourceLoader.getPrompts();
@@ -1096,8 +1191,11 @@ export class InteractiveMode {
const contextList = contextFiles
.map((f) => theme.fg("dim", ` ${this.formatDisplayPath(f.path)}`))
.join("\n");
this.chatContainer.addChild(new Text(`${sectionHeader("Context")}\n${contextList}`, 0, 0));
this.chatContainer.addChild(new Spacer(1));
const contextCompactList = formatCompactList(
contextFiles.map((contextFile) => this.formatContextPath(contextFile.path)),
{ sort: false },
);
addLoadedSection("Context", contextCompactList, contextList);
}
const skills = skillsResult.skills;
@@ -1109,8 +1207,8 @@ export class InteractiveMode {
formatPath: (item) => this.formatDisplayPath(item.path),
formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo),
});
this.chatContainer.addChild(new Text(`${sectionHeader("Skills")}\n${skillList}`, 0, 0));
this.chatContainer.addChild(new Spacer(1));
const skillCompactList = formatCompactList(skills.map((skill) => skill.name));
addLoadedSection("Skills", skillCompactList, skillList);
}
const templates = this.session.promptTemplates;
@@ -1129,8 +1227,8 @@ export class InteractiveMode {
return template ? `/${template.name}` : this.formatDisplayPath(item.path);
},
});
this.chatContainer.addChild(new Text(`${sectionHeader("Prompts")}\n${templateList}`, 0, 0));
this.chatContainer.addChild(new Spacer(1));
const promptCompactList = formatCompactList(templates.map((template) => `/${template.name}`));
addLoadedSection("Prompts", promptCompactList, templateList);
}
if (extensions.length > 0) {
@@ -1139,8 +1237,10 @@ export class InteractiveMode {
formatPath: (item) => this.formatDisplayPath(item.path),
formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo),
});
this.chatContainer.addChild(new Text(`${sectionHeader("Extensions", "mdHeading")}\n${extList}`, 0, 0));
this.chatContainer.addChild(new Spacer(1));
const extensionCompactList = formatCompactList(
extensions.map((extension) => compactPath(extension.path, extension.sourceInfo)),
);
addLoadedSection("Extensions", extensionCompactList, extList, "mdHeading");
}
// Show loaded themes (excluding built-in)
@@ -1157,8 +1257,12 @@ export class InteractiveMode {
formatPath: (item) => this.formatDisplayPath(item.path),
formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo),
});
this.chatContainer.addChild(new Text(`${sectionHeader("Themes")}\n${themeList}`, 0, 0));
this.chatContainer.addChild(new Spacer(1));
const themeCompactList = formatCompactList(
customThemes.map(
(loadedTheme) => loadedTheme.name ?? compactPath(loadedTheme.sourcePath!, loadedTheme.sourceInfo),
),
);
addLoadedSection("Themes", themeCompactList, themeList);
}
}
@@ -1607,6 +1711,9 @@ export class InteractiveMode {
if (factory) {
// Create and add custom header
this.customHeader = factory(this.ui, theme);
if (isExpandable(this.customHeader)) {
this.customHeader.setExpanded(this.toolOutputExpanded);
}
if (index !== -1) {
this.headerContainer.children[index] = this.customHeader;
} else {
@@ -1616,6 +1723,9 @@ export class InteractiveMode {
} else {
// Restore built-in header
this.customHeader = undefined;
if (isExpandable(this.builtInHeader)) {
this.builtInHeader.setExpanded(this.toolOutputExpanded);
}
if (index !== -1) {
this.headerContainer.children[index] = this.builtInHeader;
}
@@ -2168,12 +2278,12 @@ export class InteractiveMode {
await this.handleModelCommand(searchTerm);
return;
}
if (text.startsWith("/export")) {
if (text === "/export" || text.startsWith("/export ")) {
await this.handleExportCommand(text);
this.editor.setText("");
return;
}
if (text.startsWith("/import")) {
if (text === "/import" || text.startsWith("/import ")) {
await this.handleImportCommand(text);
this.editor.setText("");
return;
@@ -3080,6 +3190,10 @@ export class InteractiveMode {
private setToolsExpanded(expanded: boolean): void {
this.toolOutputExpanded = expanded;
const activeHeader = this.customHeader ?? this.builtInHeader;
if (isExpandable(activeHeader)) {
activeHeader.setExpanded(expanded);
}
for (const child of this.chatContainer.children) {
if (isExpandable(child)) {
child.setExpanded(expanded);
@@ -4023,6 +4137,7 @@ export class InteractiveMode {
private async showLoginDialog(providerId: string): Promise<void> {
const providerInfo = this.session.modelRegistry.authStorage.getOAuthProviders().find((p) => p.id === providerId);
const providerName = providerInfo?.name || providerId;
const previousModel = this.session.model;
// Providers that use callback servers (can paste redirect URL)
const usesCallbackServer = providerInfo?.usesCallbackServer ?? false;
@@ -4098,8 +4213,50 @@ export class InteractiveMode {
// Success
restoreEditor();
this.session.modelRegistry.refresh();
let selectedModel: Model<any> | undefined;
let selectionError: string | undefined;
if (isUnknownModel(previousModel)) {
const availableModels = this.session.modelRegistry.getAvailable();
const providerModels = availableModels.filter((model) => model.provider === providerId);
if (!hasDefaultModelProvider(providerId)) {
selectionError = `Logged in to ${providerName}, but no default model is configured for provider "${providerId}". Use /model to select a model.`;
} else if (providerModels.length === 0) {
selectionError = `Logged in to ${providerName}, but no models are available for that provider. Use /model to select a model.`;
} else {
const defaultModelId = defaultModelPerProvider[providerId];
selectedModel = providerModels.find((model) => model.id === defaultModelId);
if (!selectedModel) {
selectionError = `Logged in to ${providerName}, but its default model "${defaultModelId}" is not available. Use /model to select a model.`;
} else {
try {
await this.session.setModel(selectedModel);
} catch (error: unknown) {
selectedModel = undefined;
const errorMessage = error instanceof Error ? error.message : String(error);
selectionError = `Logged in to ${providerName}, but selecting its default model failed: ${errorMessage}. Use /model to select a model.`;
}
}
}
}
await this.updateAvailableProviderCount();
this.showStatus(`Logged in to ${providerName}. Credentials saved to ${getAuthPath()}`);
this.footer.invalidate();
this.updateEditorBorderColor();
if (selectedModel) {
this.showStatus(
`Logged in to ${providerName}. Selected ${selectedModel.id}. Credentials saved to ${getAuthPath()}`,
);
void this.maybeWarnAboutAnthropicSubscriptionAuth(selectedModel);
this.checkDaxnutsEasterEgg(selectedModel);
} else {
this.showStatus(`Logged in to ${providerName}. Credentials saved to ${getAuthPath()}`);
if (selectionError) {
this.showError(selectionError);
} else {
void this.maybeWarnAboutAnthropicSubscriptionAuth();
}
}
} catch (error: unknown) {
restoreEditor();
const errorMsg = error instanceof Error ? error.message : String(error);
@@ -4152,6 +4309,10 @@ export class InteractiveMode {
try {
await this.session.reload();
this.keybindings.reload();
const activeHeader = this.customHeader ?? this.builtInHeader;
if (isExpandable(activeHeader)) {
activeHeader.setExpanded(this.toolOutputExpanded);
}
setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
const themeName = this.settingsManager.getTheme();
@@ -4192,8 +4353,7 @@ export class InteractiveMode {
}
private async handleExportCommand(text: string): Promise<void> {
const parts = text.split(/\s+/);
const outputPath = parts.length > 1 ? parts[1] : undefined;
const outputPath = this.getPathCommandArgument(text, "/export");
try {
if (outputPath?.endsWith(".jsonl")) {
@@ -4208,13 +4368,41 @@ export class InteractiveMode {
}
}
private getPathCommandArgument(text: string, command: "/export" | "/import"): string | undefined {
if (text === command) {
return undefined;
}
if (!text.startsWith(`${command} `)) {
return undefined;
}
const argsString = text.slice(command.length + 1).trimStart();
if (!argsString) {
return undefined;
}
const firstChar = argsString[0];
if (firstChar === '"' || firstChar === "'") {
const closingQuoteIndex = argsString.indexOf(firstChar, 1);
if (closingQuoteIndex < 0) {
return undefined;
}
return argsString.slice(1, closingQuoteIndex);
}
const firstWhitespaceIndex = argsString.search(/\s/);
if (firstWhitespaceIndex < 0) {
return argsString;
}
return argsString.slice(0, firstWhitespaceIndex);
}
private async handleImportCommand(text: string): Promise<void> {
const parts = text.split(/\s+/);
if (parts.length < 2 || !parts[1]) {
const inputPath = this.getPathCommandArgument(text, "/import");
if (!inputPath) {
this.showError("Usage: /import <path.jsonl>");
return;
}
const inputPath = parts[1];
const confirmed = await this.showExtensionConfirm("Import session", `Replace current session with ${inputPath}?`);
if (!confirmed) {
@@ -4253,6 +4441,10 @@ export class InteractiveMode {
this.showStatus(`Session imported from: ${inputPath}`);
return;
}
if (error instanceof SessionImportFileNotFoundError) {
this.showError(`Failed to import session: ${error.message}`);
return;
}
await this.handleFatalRuntimeError("Failed to import session", error);
}
}

View File

@@ -0,0 +1,144 @@
import { describe, expect, it, vi } from "vitest";
import { SessionImportFileNotFoundError } from "../src/core/agent-session-runtime.js";
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js";
type PathCommand = "/export" | "/import";
type InteractiveModePrototype = {
getPathCommandArgument(this: unknown, text: string, command: PathCommand): string | undefined;
handleImportCommand(this: ImportCommandContext, text: string): Promise<void>;
};
type ImportCommandContext = {
loadingAnimation?: { stop: () => void };
statusContainer: { clear: () => void };
runtimeHost: { importFromJsonl: (inputPath: string, cwdOverride?: string) => Promise<{ cancelled: boolean }> };
showError: (message: string) => void;
showStatus: (message: string) => void;
showExtensionConfirm: (title: string, message: string) => Promise<boolean>;
handleRuntimeSessionChange: () => Promise<void>;
renderCurrentSessionState: () => void;
handleFatalRuntimeError: (prefix: string, error: unknown) => Promise<never>;
promptForMissingSessionCwd: (error: unknown) => Promise<string | undefined>;
getPathCommandArgument: (text: string, command: PathCommand) => string | undefined;
};
const interactiveModePrototype = InteractiveMode.prototype as unknown as InteractiveModePrototype;
describe("InteractiveMode /import parsing", () => {
it("strips quotes from /import path arguments", () => {
expect(interactiveModePrototype.getPathCommandArgument('/import "path/to/session.jsonl"', "/import")).toBe(
"path/to/session.jsonl",
);
expect(
interactiveModePrototype.getPathCommandArgument('/import "path with spaces/session.jsonl"', "/import"),
).toBe("path with spaces/session.jsonl");
});
it("preserves apostrophes in unquoted /import path arguments", () => {
expect(interactiveModePrototype.getPathCommandArgument("/import john's/session.jsonl", "/import")).toBe(
"john's/session.jsonl",
);
});
it("enforces command token boundaries", () => {
expect(interactiveModePrototype.getPathCommandArgument("/important /tmp/session.jsonl", "/import")).toBe(
undefined,
);
expect(interactiveModePrototype.getPathCommandArgument("/exporter out.html", "/export")).toBe(undefined);
expect(interactiveModePrototype.getPathCommandArgument("/import /tmp/session.jsonl", "/import")).toBe(
"/tmp/session.jsonl",
);
});
it("passes unquoted path to runtimeHost.importFromJsonl", async () => {
const importFromJsonl = vi.fn(async () => ({ cancelled: false }));
const showExtensionConfirm = vi.fn(async () => true);
const showStatus = vi.fn();
const showError = vi.fn();
const context: ImportCommandContext = {
statusContainer: { clear: vi.fn() },
runtimeHost: { importFromJsonl },
showError,
showStatus,
showExtensionConfirm,
handleRuntimeSessionChange: vi.fn(async () => {}),
renderCurrentSessionState: vi.fn(),
handleFatalRuntimeError: vi.fn(async () => {
throw new Error("unexpected fatal error");
}),
promptForMissingSessionCwd: vi.fn(async () => undefined),
getPathCommandArgument: interactiveModePrototype.getPathCommandArgument,
};
await interactiveModePrototype.handleImportCommand.call(context, '/import "path/to/session.jsonl"');
expect(showExtensionConfirm).toHaveBeenCalledWith(
"Import session",
"Replace current session with path/to/session.jsonl?",
);
expect(importFromJsonl).toHaveBeenCalledWith("path/to/session.jsonl");
expect(showError).not.toHaveBeenCalled();
expect(showStatus).toHaveBeenCalledWith("Session imported from: path/to/session.jsonl");
});
it("passes unquoted apostrophe path to runtimeHost.importFromJsonl unchanged", async () => {
const importFromJsonl = vi.fn(async () => ({ cancelled: false }));
const showExtensionConfirm = vi.fn(async () => true);
const showStatus = vi.fn();
const showError = vi.fn();
const context: ImportCommandContext = {
statusContainer: { clear: vi.fn() },
runtimeHost: { importFromJsonl },
showError,
showStatus,
showExtensionConfirm,
handleRuntimeSessionChange: vi.fn(async () => {}),
renderCurrentSessionState: vi.fn(),
handleFatalRuntimeError: vi.fn(async () => {
throw new Error("unexpected fatal error");
}),
promptForMissingSessionCwd: vi.fn(async () => undefined),
getPathCommandArgument: interactiveModePrototype.getPathCommandArgument,
};
await interactiveModePrototype.handleImportCommand.call(context, "/import john's/session.jsonl");
expect(importFromJsonl).toHaveBeenCalledWith("john's/session.jsonl");
expect(showError).not.toHaveBeenCalled();
expect(showStatus).toHaveBeenCalledWith("Session imported from: john's/session.jsonl");
});
it("shows a non-fatal error when /import path does not exist", async () => {
const importFromJsonl = vi.fn(async () => {
throw new SessionImportFileNotFoundError("/tmp/missing-session.jsonl");
});
const showExtensionConfirm = vi.fn(async () => true);
const showStatus = vi.fn();
const showError = vi.fn();
const handleFatalRuntimeError = vi.fn(async () => {
throw new Error("unexpected fatal error");
});
const context: ImportCommandContext = {
statusContainer: { clear: vi.fn() },
runtimeHost: { importFromJsonl },
showError,
showStatus,
showExtensionConfirm,
handleRuntimeSessionChange: vi.fn(async () => {}),
renderCurrentSessionState: vi.fn(),
handleFatalRuntimeError,
promptForMissingSessionCwd: vi.fn(async () => undefined),
getPathCommandArgument: interactiveModePrototype.getPathCommandArgument,
};
await interactiveModePrototype.handleImportCommand.call(context, "/import /tmp/missing-session.jsonl");
expect(showError).toHaveBeenCalledWith("Failed to import session: File not found: /tmp/missing-session.jsonl");
expect(showStatus).not.toHaveBeenCalled();
expect(handleFatalRuntimeError).not.toHaveBeenCalled();
});
});

View File

@@ -1,3 +1,5 @@
import { homedir } from "node:os";
import * as path from "node:path";
import { Container } from "@mariozechner/pi-tui";
import { beforeAll, describe, expect, test, vi } from "vitest";
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js";
@@ -60,6 +62,27 @@ describe("InteractiveMode.showStatus", () => {
});
});
describe("InteractiveMode.setToolsExpanded", () => {
test("applies expansion state to the active header and chat entries", () => {
const header = { setExpanded: vi.fn() };
const chatChild = { setExpanded: vi.fn() };
const fakeThis: any = {
toolOutputExpanded: false,
customHeader: undefined,
builtInHeader: header,
chatContainer: { children: [chatChild] },
ui: { requestRender: vi.fn() },
};
(InteractiveMode as any).prototype.setToolsExpanded.call(fakeThis, true);
expect(fakeThis.toolOutputExpanded).toBe(true);
expect(header.setExpanded).toHaveBeenCalledWith(true);
expect(chatChild.setExpanded).toHaveBeenCalledWith(true);
expect(fakeThis.ui.requestRender).toHaveBeenCalledTimes(1);
});
});
describe("InteractiveMode.createExtensionUIContext setTheme", () => {
test("persists theme changes to settings manager", () => {
initTheme("dark");
@@ -116,31 +139,41 @@ describe("InteractiveMode.showLoadedResources", () => {
function createShowLoadedResourcesThis(options: {
quietStartup: boolean;
verbose?: boolean;
skills?: Array<{ filePath: string }>;
toolOutputExpanded?: boolean;
cwd?: string;
contextFiles?: Array<{ path: string; content?: string }>;
extensions?: Array<{ path: string }>;
skills?: Array<{ filePath: string; name: string }>;
skillDiagnostics?: Array<{ type: "warning" | "error" | "collision"; message: string }>;
}) {
const fakeThis: any = {
options: { verbose: options.verbose ?? false },
toolOutputExpanded: options.toolOutputExpanded ?? false,
chatContainer: new Container(),
settingsManager: {
getQuietStartup: () => options.quietStartup,
},
sessionManager: {
getCwd: () => options.cwd ?? "/tmp/project",
},
session: {
promptTemplates: [],
extensionRunner: undefined,
resourceLoader: {
getPathMetadata: () => new Map(),
getAgentsFiles: () => ({ agentsFiles: [] }),
getAgentsFiles: () => ({ agentsFiles: options.contextFiles ?? [] }),
getSkills: () => ({
skills: options.skills ?? [],
diagnostics: options.skillDiagnostics ?? [],
}),
getPrompts: () => ({ prompts: [], diagnostics: [] }),
getExtensions: () => ({ extensions: [], errors: [], runtime: {} }),
getExtensions: () => ({ extensions: options.extensions ?? [], errors: [], runtime: {} }),
getThemes: () => ({ themes: [], diagnostics: [] }),
},
},
formatDisplayPath: (p: string) => p,
formatDisplayPath: (p: string) => (InteractiveMode as any).prototype.formatDisplayPath.call(fakeThis, p),
formatContextPath: (p: string) => (InteractiveMode as any).prototype.formatContextPath.call(fakeThis, p),
getStartupExpansionState: () => (InteractiveMode as any).prototype.getStartupExpansionState.call(fakeThis),
buildScopeGroups: () => [],
formatScopeGroups: () => "resource-list",
getShortPath: (p: string) => p,
@@ -151,10 +184,117 @@ describe("InteractiveMode.showLoadedResources", () => {
return fakeThis;
}
test("shows a compact resource listing by default", () => {
const fakeThis = createShowLoadedResourcesThis({
quietStartup: false,
skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }],
});
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
force: false,
});
const output = renderAll(fakeThis.chatContainer);
expect(output).toContain("[Skills]");
expect(output).toContain("commit");
expect(output).not.toContain("resource-list");
});
test("shows full resource listing when expanded", () => {
const fakeThis = createShowLoadedResourcesThis({
quietStartup: false,
toolOutputExpanded: true,
skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }],
});
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
force: false,
});
const output = renderAll(fakeThis.chatContainer);
expect(output).toContain("[Skills]");
expect(output).toContain("resource-list");
expect(output).not.toContain("commit");
});
test("shows full resource listing on verbose startup even when tool output is collapsed", () => {
const fakeThis = createShowLoadedResourcesThis({
quietStartup: true,
verbose: true,
toolOutputExpanded: false,
skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }],
});
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
force: false,
});
const output = renderAll(fakeThis.chatContainer);
expect(output).toContain("[Skills]");
expect(output).toContain("resource-list");
expect(output).not.toContain("commit");
});
test("abbreviates extensions in compact listing", () => {
const fakeThis = createShowLoadedResourcesThis({
quietStartup: false,
extensions: [{ path: "/tmp/extensions/answer.ts" }, { path: "/tmp/extensions/btw.ts" }],
});
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
force: false,
});
const output = renderAll(fakeThis.chatContainer);
expect(output).toContain("[Extensions]");
expect(output).toContain("answer.ts, btw.ts");
expect(output).not.toContain("extensions/answer.ts");
});
test("shows context paths relative to cwd while preserving full external paths", () => {
const home = homedir();
const cwd = path.join(home, "Development", "pi-mono");
const fakeThis = createShowLoadedResourcesThis({
quietStartup: false,
cwd,
contextFiles: [{ path: path.join(home, ".pi", "agent", "AGENTS.md") }, { path: path.join(cwd, "AGENTS.md") }],
});
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
force: false,
});
const output = renderAll(fakeThis.chatContainer).replace(/\\/g, "/");
expect(output).toContain("[Context]");
expect(output).toContain("~/.pi/agent/AGENTS.md, AGENTS.md");
expect(output).not.toContain(`${cwd.replace(/\\/g, "/")}/AGENTS.md`);
});
test("shows full context paths when expanded", () => {
const home = homedir();
const cwd = path.join(home, "Development", "pi-mono");
const fakeThis = createShowLoadedResourcesThis({
quietStartup: false,
toolOutputExpanded: true,
cwd,
contextFiles: [{ path: path.join(home, ".pi", "agent", "AGENTS.md") }, { path: path.join(cwd, "AGENTS.md") }],
});
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
force: false,
});
const output = renderAll(fakeThis.chatContainer).replace(/\\/g, "/");
expect(output).toContain("[Context]");
expect(output).toContain("~/.pi/agent/AGENTS.md");
expect(output).toContain("~/Development/pi-mono/AGENTS.md");
expect(output).not.toContain("~/.pi/agent/AGENTS.md, AGENTS.md");
});
test("does not show verbose listing on quiet startup during reload", () => {
const fakeThis = createShowLoadedResourcesThis({
quietStartup: true,
skills: [{ filePath: "/tmp/skill/SKILL.md" }],
skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }],
});
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
@@ -169,7 +309,7 @@ describe("InteractiveMode.showLoadedResources", () => {
test("still shows diagnostics on quiet startup when requested", () => {
const fakeThis = createShowLoadedResourcesThis({
quietStartup: true,
skills: [{ filePath: "/tmp/skill/SKILL.md" }],
skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }],
skillDiagnostics: [{ type: "warning", message: "duplicate skill name" }],
});

View File

@@ -8,8 +8,11 @@
* - Edge cases and integration between parsing and substitution
*/
import { describe, expect, test } from "vitest";
import { parseCommandArgs, substituteArgs } from "../src/core/prompt-templates.js";
import { mkdirSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { afterAll, describe, expect, test } from "vitest";
import { loadPromptTemplates, parseCommandArgs, substituteArgs } from "../src/core/prompt-templates.js";
// ============================================================================
// substituteArgs
@@ -379,3 +382,123 @@ describe("parseCommandArgs + substituteArgs integration", () => {
expect(substituteArgs(template1, args)).toBe(substituteArgs(template2, args));
});
});
// ============================================================================
// loadPromptTemplates - argument-hint frontmatter
// ============================================================================
describe("loadPromptTemplates - argument-hint", () => {
const testDir = join(tmpdir(), `pi-test-prompts-${Date.now()}`);
function writeTemplate(name: string, content: string) {
mkdirSync(testDir, { recursive: true });
writeFileSync(join(testDir, `${name}.md`), content);
}
test("should parse required argument-hint from frontmatter", () => {
writeTemplate(
"pr",
`---
description: Review PRs from URLs with structured issue and code analysis
argument-hint: "<PR-URL>"
---
You are given one or more GitHub PR URLs: $@`,
);
const templates = loadPromptTemplates({
promptPaths: [testDir],
includeDefaults: false,
});
const pr = templates.find((t) => t.name === "pr");
expect(pr).toBeDefined();
expect(pr!.argumentHint).toBe("<PR-URL>");
expect(pr!.description).toBe("Review PRs from URLs with structured issue and code analysis");
});
test("should parse optional argument-hint from frontmatter", () => {
writeTemplate(
"wr",
`---
description: Finish the current task end-to-end with changelog, commit, and push
argument-hint: "[instructions]"
---
Wrap it. Additional instructions: $ARGUMENTS`,
);
const templates = loadPromptTemplates({
promptPaths: [testDir],
includeDefaults: false,
});
const wr = templates.find((t) => t.name === "wr");
expect(wr).toBeDefined();
expect(wr!.argumentHint).toBe("[instructions]");
expect(wr!.description).toBe("Finish the current task end-to-end with changelog, commit, and push");
});
test("should leave argumentHint undefined when not specified", () => {
writeTemplate(
"cl",
`---
description: Audit changelog entries before release
---
Audit changelog entries for all commits since the last release.`,
);
const templates = loadPromptTemplates({
promptPaths: [testDir],
includeDefaults: false,
});
const cl = templates.find((t) => t.name === "cl");
expect(cl).toBeDefined();
expect(cl!.argumentHint).toBeUndefined();
});
test("should ignore empty argument-hint", () => {
writeTemplate(
"empty-hint",
`---
description: A command with empty hint
argument-hint: ""
---
Do something`,
);
const templates = loadPromptTemplates({
promptPaths: [testDir],
includeDefaults: false,
});
const tmpl = templates.find((t) => t.name === "empty-hint");
expect(tmpl).toBeDefined();
expect(tmpl!.argumentHint).toBeUndefined();
});
test("should preserve argument-hint with special characters", () => {
writeTemplate(
"is",
`---
description: Analyze GitHub issues (bugs or feature requests)
argument-hint: "<issue>"
---
Analyze GitHub issue(s): $ARGUMENTS`,
);
const templates = loadPromptTemplates({
promptPaths: [testDir],
includeDefaults: false,
});
const is = templates.find((t) => t.name === "is");
expect(is).toBeDefined();
expect(is!.argumentHint).toBe("<issue>");
});
afterAll(() => {
try {
rmSync(testDir, { recursive: true, force: true });
} catch {}
});
});

View File

@@ -142,6 +142,17 @@ export async function createHarness(options: HarnessOptions = {}): Promise<Harne
}
return runner.emitBeforeProviderRequest(payload);
},
onResponse: async (response) => {
const runner = extensionRunnerRef.current;
if (!runner?.hasHandlers("after_provider_response")) {
return;
}
await runner.emit({
type: "after_provider_response",
status: response.status,
headers: response.headers,
});
},
transformContext: async (messages: AgentMessage[]) => {
const runner = extensionRunnerRef.current;
if (!runner) return messages;

View File

@@ -0,0 +1,72 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createFindToolDefinition } from "../../../src/core/tools/find.js";
/**
* Regression test for https://github.com/badlogic/pi-mono/issues/3302
*
* The `find` tool advertises glob patterns like `src/**\/*.spec.ts`, but the
* default fd-backed implementation used `fd --glob <pattern>` without
* `--full-path`, which makes fd match only against the basename. Any pattern
* containing a `/` therefore silently returned no matches.
*
* The fix switches fd into full-path mode when the pattern contains a `/`
* and prepends `**\/` so the pattern can match against the absolute candidate
* path that fd feeds to the matcher.
*/
describe("issue #3302 find returns no results for path-based glob patterns", () => {
let tempRoot: string;
beforeEach(() => {
tempRoot = mkdtempSync(join(tmpdir(), "pi-3302-"));
mkdirSync(join(tempRoot, "some", "parent", "child"), { recursive: true });
mkdirSync(join(tempRoot, "src", "foo", "bar"), { recursive: true });
writeFileSync(join(tempRoot, "some", "parent", "child", "file.ext"), "");
writeFileSync(join(tempRoot, "some", "parent", "child", "test.spec.ts"), "");
writeFileSync(join(tempRoot, "src", "foo", "bar", "example.spec.ts"), "");
});
afterEach(() => {
rmSync(tempRoot, { recursive: true, force: true });
});
async function runFind(pattern: string): Promise<string[]> {
const def = createFindToolDefinition(tempRoot);
// The find tool implementation does not touch ctx; pass a minimal stub.
const ctx = {} as Parameters<typeof def.execute>[4];
const result = (await def.execute("call-1", { pattern }, undefined, undefined, ctx)) as {
content: Array<{ type: string; text?: string }>;
};
const text = result.content[0]?.text ?? "";
if (text === "No files found matching pattern") return [];
return text
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0 && !l.startsWith("["));
}
it("basename pattern still matches (regression-safe)", async () => {
const files = await runFind("*.spec.ts");
expect(files.sort()).toEqual(["some/parent/child/test.spec.ts", "src/foo/bar/example.spec.ts"]);
});
it("directory-prefixed pattern with ** tail matches subtree", async () => {
const files = await runFind("some/parent/child/**");
// Matches files (and possibly directories) under the subtree. Assert the two files are present.
expect(files).toContain("some/parent/child/file.ext");
expect(files).toContain("some/parent/child/test.spec.ts");
});
it("leading ** wildcard with path segments matches", async () => {
const files = await runFind("**/parent/child/*");
expect(files.sort()).toContain("some/parent/child/file.ext");
expect(files.sort()).toContain("some/parent/child/test.spec.ts");
});
it("src/**/*.spec.ts matches nested spec file", async () => {
const files = await runFind("src/**/*.spec.ts");
expect(files).toEqual(["src/foo/bar/example.spec.ts"]);
});
});

View File

@@ -0,0 +1,83 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createFindToolDefinition } from "../../../src/core/tools/find.js";
/**
* Regression test for https://github.com/badlogic/pi-mono/issues/3303
*
* The `find` tool previously collected every `.gitignore` under the search
* path and passed them to `fd` via `--ignore-file`. fd treats `--ignore-file`
* entries as a single global ignore source, so rules from `a/.gitignore`
* also filtered files under sibling `b/`. The fix switches to fd's
* hierarchical `.gitignore` handling via `--no-require-git` and drops the
* manual collection.
*/
describe("issue #3303 nested .gitignore rules leak into sibling directories", () => {
let tempRoot: string;
async function runFind(pattern: string): Promise<string[]> {
const def = createFindToolDefinition(tempRoot);
const ctx = {} as Parameters<typeof def.execute>[4];
const result = (await def.execute("call-1", { pattern }, undefined, undefined, ctx)) as {
content: Array<{ type: string; text?: string }>;
};
const text = result.content[0]?.text ?? "";
if (text === "No files found matching pattern") return [];
return text
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0 && !l.startsWith("["))
.sort();
}
afterEach(() => {
if (tempRoot) rmSync(tempRoot, { recursive: true, force: true });
});
describe("flat sibling case", () => {
beforeEach(() => {
tempRoot = mkdtempSync(join(tmpdir(), "pi-3303-flat-"));
mkdirSync(join(tempRoot, "a"), { recursive: true });
mkdirSync(join(tempRoot, "b"), { recursive: true });
writeFileSync(join(tempRoot, "a", ".gitignore"), "ignored.txt\n");
writeFileSync(join(tempRoot, "a", "ignored.txt"), "");
writeFileSync(join(tempRoot, "a", "kept.txt"), "");
writeFileSync(join(tempRoot, "b", "ignored.txt"), "");
writeFileSync(join(tempRoot, "b", "kept.txt"), "");
writeFileSync(join(tempRoot, "root.txt"), "");
});
it("applies a/.gitignore only inside a/ and leaves b/ untouched", async () => {
const files = await runFind("**/*.txt");
expect(files).toEqual(["a/kept.txt", "b/ignored.txt", "b/kept.txt", "root.txt"]);
});
});
describe("deeply nested case", () => {
beforeEach(() => {
tempRoot = mkdtempSync(join(tmpdir(), "pi-3303-deep-"));
mkdirSync(join(tempRoot, "a", "deep"), { recursive: true });
mkdirSync(join(tempRoot, "b"), { recursive: true });
writeFileSync(join(tempRoot, "a", ".gitignore"), "ignored.txt\n");
writeFileSync(join(tempRoot, "a", "deep", ".gitignore"), "secret.txt\n");
writeFileSync(join(tempRoot, "a", "ignored.txt"), "");
writeFileSync(join(tempRoot, "a", "kept.txt"), "");
writeFileSync(join(tempRoot, "a", "deep", "ignored.txt"), "");
writeFileSync(join(tempRoot, "a", "deep", "secret.txt"), "");
writeFileSync(join(tempRoot, "a", "deep", "kept.txt"), "");
writeFileSync(join(tempRoot, "b", "ignored.txt"), "");
writeFileSync(join(tempRoot, "b", "kept.txt"), "");
writeFileSync(join(tempRoot, "root.txt"), "");
});
it("scopes each .gitignore to its own subtree", async () => {
const files = await runFind("**/*.txt");
// a/.gitignore ignores 'ignored.txt' within a/ and a/deep/.
// a/deep/.gitignore additionally ignores 'secret.txt' within a/deep/.
// b/ is untouched by either.
expect(files).toEqual(["a/deep/kept.txt", "a/kept.txt", "b/ignored.txt", "b/kept.txt", "root.txt"]);
});
});
});

View File

@@ -557,6 +557,15 @@ describe("Coding Agent Tools", () => {
expect(output).toContain("kept.txt");
expect(output).not.toContain("ignored.txt");
});
it("should surface fd glob parse errors", async () => {
await expect(
findTool.execute("test-call-15", {
pattern: "[",
path: testDir,
}),
).rejects.toThrow(/error parsing glob|fd exited with code 1|fd error/i);
});
});
describe("ls tool", () => {