fix: backport fixes from pi-mono (52532c7c0..82d7da878)

packages/ai:
- fix: default tool call arguments to {} when nullish
- feat: add Vercel AI Gateway routing support

packages/tui:
- feat: add Ctrl+B/Ctrl+F cursor navigation keybindings
- feat: add character jump navigation (Ctrl+], Ctrl+Alt+])
- fix: support quoted paths with spaces in autocomplete

packages/coding-agent:
- feat: add set_session_name RPC command
- feat: add "none" option to doubleEscapeAction setting
- fix: respect .gitignore/.ignore/.fdignore when scanning package resources
- refactor: unified sessionTitle to sessionName across codebase
This commit is contained in:
can1357
2026-02-01 08:13:57 +01:00
parent ef415e863b
commit 696cd48f4e
23 changed files with 602 additions and 134 deletions
+3
View File
@@ -84,6 +84,7 @@
"file-type": "^21.3.0",
"glob": "^13.0.0",
"handlebars": "^4.7.8",
"ignore": "^7.0.5",
"marked": "^17.0.1",
"nanoid": "^5.1.6",
"node-html-parser": "^7.0.2",
@@ -771,6 +772,8 @@
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
+60 -8
View File
@@ -5,13 +5,13 @@ Use it for any merge: single file, feature branch, or full release sync.
## Last Sync Point
**Commit:** `52532c7c0`
**Date:** 2026-01-29
**Commit:** `82d7da878`
**Date:** 2026-01-30
When starting a new sync, generate patches from this commit forward:
```bash
git format-patch 3635e45f..HEAD --stdout > changes.patch
git format-patch 82d7da878..HEAD --stdout > changes.patch
```
## 0) Define the scope
@@ -165,10 +165,10 @@ the improvements and add explicit checks so they don’t get lost in the merge.
didn’t revert (e.g., new config precedence, disabled features, tool lists).
- **Audit env/shell behavior**: if you fixed execution or sandboxing, verify the new path still uses your
sanitized env and does not reintroduce alias/function overrides.
- **Re-run targeted samples**: keep a minimal set of “known good” examples and run them after the port
- **Re-run targeted samples**: keep a minimal set of "known good" examples and run them after the port
(CLI flags, extension registration, tool execution).
## 11) Detect and handle reworked code
## 12) Detect and handle reworked code
Before porting a file, check if upstream significantly refactored it:
@@ -220,7 +220,7 @@ git show upstream/main:path/to/file.ts | rg "default|DEFAULT"
rg "case \"" path/to/file.ts
```
## 12) Quick audit checklist
## 13) Quick audit checklist
Use this as a final pass before you finish:
@@ -230,9 +230,53 @@ Use this as a final pass before you finish:
- [ ] `package.json` scripts use Bun
- [ ] Assets load via Bun embed patterns (no copy scripts)
- [ ] Tests or checks run (or explicitly noted as blocked)
- [ ] No functionality regressions (see section 11)
- [ ] No functionality regressions (see sections 11-12)
## 13) Intentional Divergences
## 14) Commit message format
When committing a backport, use this format:
```
fix: backport fixes from pi-mono (<from>..<to>)
packages/<package>:
- <type>: <description>
- <type>: <description> (#<issue> by @<contributor>)
packages/<other-package>:
- <type>: <description>
```
**Example:**
```
fix: backport fixes from pi-mono (9f3eef65f..52532c7c0)
packages/ai:
- fix: handle "sensitive" stop reason from Anthropic API
- fix: normalize tool call IDs with special characters for Responses API
- fix: add overflow detection for Bedrock, MiniMax, Kimi providers
- fix: 429 status is rate limiting, not context overflow
packages/tui:
- fix: refactored autocomplete state tracking
- fix: file autocomplete should not trigger on empty text
- fix: configurable autocomplete max visible items
- fix: improved table column width calculation with word-aware wrapping
packages/coding-agent:
- fix: preserve external config.yml edits on save (#1046 by @nicobailonMD)
- fix: resolve macOS NFD and curly quote variants in file paths
```
**Rules:**
- Group changes by package
- Use conventional commit types (`fix`, `feat`, `refactor`, `perf`, `docs`)
- Include upstream issue/PR numbers and contributor attribution for external contributions
- The commit range in the title helps track sync points
## 15) Intentional Divergences
Our fork has architectural decisions that differ from upstream. **Do not port these upstream patterns:**
@@ -254,6 +298,14 @@ Our fork has architectural decisions that differ from upstream. **Do not port th
| `ExtensionInputComponent` | `HookInputComponent` |
| `ExtensionSelectorComponent` | `HookSelectorComponent` |
### API Naming
| Upstream | Our Fork | Notes |
| ----------------------------------------- | ----------------------------------------- | ------------------------------------------ |
| `sessionManager.appendSessionInfo(name)` | `sessionManager.setSessionName(name)` | We use `sessionName` throughout |
| `sessionManager.getSessionName()` | `sessionManager.getSessionName()` | Same (we unified to match upstream's RPC) |
| `agent.sessionName` / `setSessionName()` | `agent.sessionName` / `setSessionName()` | Same |
### File Consolidation
| Upstream | Our Fork | Reason |
+2 -2
View File
@@ -208,7 +208,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
type: "toolCall",
id: event.content_block.id,
name: isOAuthToken ? fromClaudeCodeName(event.content_block.name) : event.content_block.name,
arguments: event.content_block.input as Record<string, any>,
arguments: (event.content_block.input as Record<string, any>) ?? {},
partialJson: "",
index: event.index,
};
@@ -842,7 +842,7 @@ function convertMessages(
type: "tool_use",
id: sanitizeToolCallId(block.id),
name: isOAuthToken ? toClaudeCodeName(block.name) : block.name,
input: block.arguments,
input: block.arguments ?? {},
});
}
}
@@ -53,8 +53,9 @@ function normalizeMistralToolId(id: string, isMistral: boolean): string {
return normalized;
}
type ResolvedOpenAICompat = Required<Omit<OpenAICompat, "openRouterRouting">> & {
type ResolvedOpenAICompat = Required<Omit<OpenAICompat, "openRouterRouting" | "vercelGatewayRouting">> & {
openRouterRouting?: OpenAICompat["openRouterRouting"];
vercelGatewayRouting?: OpenAICompat["vercelGatewayRouting"];
};
/**
@@ -454,6 +455,17 @@ function buildParams(model: Model<"openai-completions">, context: Context, optio
(params as { provider?: unknown }).provider = compat.openRouterRouting;
}
// Vercel AI Gateway provider routing preferences
if (model.baseUrl.includes("ai-gateway.vercel.sh") && model.compat?.vercelGatewayRouting) {
const routing = model.compat.vercelGatewayRouting;
if (routing.only || routing.order) {
const gatewayOptions: Record<string, string[]> = {};
if (routing.only) gatewayOptions.only = routing.only;
if (routing.order) gatewayOptions.order = routing.order;
(params as any).providerOptions = { gateway: gatewayOptions };
}
}
return params;
}
@@ -822,6 +834,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAICompat
requiresReasoningContentForToolCalls: isOpenRouterKimi,
requiresAssistantContentForToolCalls: isOpenRouterKimi,
openRouterRouting: undefined,
vercelGatewayRouting: undefined,
};
}
@@ -852,5 +865,6 @@ function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompat {
requiresAssistantContentForToolCalls:
model.compat.requiresAssistantContentForToolCalls ?? detected.requiresAssistantContentForToolCalls,
openRouterRouting: model.compat.openRouterRouting ?? detected.openRouterRouting,
vercelGatewayRouting: model.compat.vercelGatewayRouting ?? detected.vercelGatewayRouting,
};
}
+14
View File
@@ -320,6 +320,8 @@ export interface OpenAICompat {
supportsToolChoice?: boolean;
/** OpenRouter-specific routing preferences. Only used when baseUrl points to OpenRouter. */
openRouterRouting?: OpenRouterRouting;
/** Vercel AI Gateway routing preferences. Only used when baseUrl points to Vercel AI Gateway. */
vercelGatewayRouting?: VercelGatewayRouting;
}
/**
@@ -334,6 +336,18 @@ export interface OpenRouterRouting {
order?: string[];
}
/**
* Vercel AI Gateway routing preferences.
* Controls which upstream providers the gateway routes requests to.
* @see https://vercel.com/docs/ai-gateway/models-and-providers/provider-options
*/
export interface VercelGatewayRouting {
/** List of provider slugs to exclusively use for this request (e.g., ["bedrock", "anthropic"]). */
only?: string[];
/** List of provider slugs to try in order (e.g., ["anthropic", "openai"]). */
order?: string[];
}
// Model interface for the unified model system
export interface Model<TApi extends Api> {
id: string;
@@ -28,6 +28,7 @@ const compat: Required<OpenAICompat> = {
requiresReasoningContentForToolCalls: false,
requiresAssistantContentForToolCalls: false,
openRouterRouting: {},
vercelGatewayRouting: {},
};
function buildToolResult(toolCallId: string, timestamp: number): ToolResultMessage {
+5
View File
@@ -16,6 +16,7 @@
- Added `Settings.init()` for initializing global settings instance
- Added `settings` global export for convenient access to settings singleton
- New `settings-schema.ts` providing unified, type-safe settings definitions with UI metadata
- Added "none" option to `doubleEscapeAction` setting to disable double-escape behavior entirely ([#973](https://github.com/badlogic/pi-mono/issues/973) by [@juanibiapina](https://github.com/juanibiapina))
### Changed
@@ -32,6 +33,10 @@
- Removed `SettingsManager.create()`, `SettingsManager.acquire()`, and `SettingsManager.inMemory()` factory methods
- Removed individual getter/setter methods from settings API (e.g., `getTheme()`, `setTheme()`, `getCompactionSettings()`)
### Fixed
- Respect .gitignore, .ignore, and .fdignore files when scanning package resources for skills, prompts, themes, and extensions
## [9.5.1] - 2026-02-01
### Changed
+1
View File
@@ -93,6 +93,7 @@
"file-type": "^21.3.0",
"glob": "^13.0.0",
"handlebars": "^4.7.8",
"ignore": "^7.0.5",
"marked": "^17.0.1",
"nanoid": "^5.1.6",
"node-html-parser": "^7.0.2",
@@ -24,6 +24,12 @@ const OpenRouterRoutingSchema = Type.Object({
order: Type.Optional(Type.Array(Type.String())),
});
// Schema for Vercel AI Gateway routing preferences
const VercelGatewayRoutingSchema = Type.Object({
only: Type.Optional(Type.Array(Type.String())),
order: Type.Optional(Type.Array(Type.String())),
});
// Schema for OpenAI compatibility settings
const OpenAICompatSchema = Type.Object({
supportsStore: Type.Optional(Type.Boolean()),
@@ -31,6 +37,7 @@ const OpenAICompatSchema = Type.Object({
supportsReasoningEffort: Type.Optional(Type.Boolean()),
maxTokensField: Type.Optional(Type.Union([Type.Literal("max_completion_tokens"), Type.Literal("max_tokens")])),
openRouterRouting: Type.Optional(OpenRouterRoutingSchema),
vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema),
});
// Schema for custom model definition
@@ -165,7 +165,7 @@ export const SETTINGS_SCHEMA = {
},
doubleEscapeAction: {
type: "enum",
values: ["branch", "tree"] as const,
values: ["branch", "tree", "none"] as const,
default: "tree",
ui: {
tab: "behavior",
+55 -11
View File
@@ -8,6 +8,7 @@ import { readDirEntries, readFile } from "../capability/fs";
import type { Skill, SkillFrontmatter } from "../capability/skill";
import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
import { parseFrontmatter } from "../utils/frontmatter";
import { addIgnoreRules, createIgnoreMatcher, type IgnoreMatcher, shouldIgnore } from "../utils/ignore-files";
const VALID_THINKING_LEVELS: readonly string[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
@@ -236,6 +237,10 @@ export async function loadSkillsFromDir(
const warnings: string[] = [];
const { dir, level, providerId, requireDescription = false } = options;
// Initialize ignore matcher and read ignore rules from root
const ig = createIgnoreMatcher();
await addIgnoreRules(ig, dir, dir, readFile);
const entries = await readDirEntries(dir);
const skillDirs = entries.filter(
entry => entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules",
@@ -243,7 +248,14 @@ export async function loadSkillsFromDir(
const results = await Promise.all(
skillDirs.map(async entry => {
const skillFile = path.join(dir, entry.name, "SKILL.md");
const entryPath = path.join(dir, entry.name);
// Check if this directory should be ignored
if (shouldIgnore(ig, dir, entryPath, true)) {
return { item: null as Skill | null, warning: null as string | null };
}
const skillFile = path.join(entryPath, "SKILL.md");
const content = await readFile(skillFile);
if (!content) {
return { item: null as Skill | null, warning: null as string | null };
@@ -311,6 +323,7 @@ export function expandEnvVarsDeep<T>(obj: T, extraEnv?: Record<string, string>):
/**
* Load files from a directory matching a pattern.
* Respects .gitignore, .ignore, and .fdignore files.
*/
export async function loadFilesFromDir<T>(
_ctx: LoadContext,
@@ -324,24 +337,47 @@ export async function loadFilesFromDir<T>(
transform: (name: string, content: string, path: string, source: SourceMeta) => T | null;
/** Whether to recurse into subdirectories */
recursive?: boolean;
/** Root directory for ignore file handling (defaults to dir) */
rootDir?: string;
/** Ignore matcher (used internally for recursion) */
ignoreMatcher?: IgnoreMatcher;
},
): Promise<LoadResult<T>> {
const rootDir = options.rootDir ?? dir;
const ig = options.ignoreMatcher ?? createIgnoreMatcher();
// Read ignore rules from this directory
await addIgnoreRules(ig, dir, rootDir, readFile);
const entries = await readDirEntries(dir);
const visibleEntries = entries.filter(entry => !entry.name.startsWith("."));
const directories = options.recursive ? visibleEntries.filter(entry => entry.isDirectory()) : [];
const directories = options.recursive
? visibleEntries.filter(entry => {
if (!entry.isDirectory()) return false;
const entryPath = path.join(dir, entry.name);
return !shouldIgnore(ig, rootDir, entryPath, true);
})
: [];
const files = visibleEntries
.filter(entry => entry.isFile())
.filter(entry => {
if (!options.extensions) return true;
return options.extensions.some(ext => entry.name.endsWith(`.${ext}`));
});
const files = visibleEntries.filter(entry => {
if (!entry.isFile()) return false;
const entryPath = path.join(dir, entry.name);
if (shouldIgnore(ig, rootDir, entryPath, false)) return false;
if (!options.extensions) return true;
return options.extensions.some(ext => entry.name.endsWith(`.${ext}`));
});
const [subResults, fileResults] = await Promise.all([
Promise.all(
directories.map(entry => loadFilesFromDir(_ctx, path.join(dir, entry.name), provider, level, options)),
directories.map(entry =>
loadFilesFromDir(_ctx, path.join(dir, entry.name), provider, level, {
...options,
rootDir,
ignoreMatcher: ig,
}),
),
),
Promise.all(
files.map(async entry => {
@@ -435,24 +471,32 @@ function isExtensionModuleFile(name: string): boolean {
* 3. Subdirectory with package.json: `extensions/<ext>/package.json` with "omp"/"pi" field → load declared paths
*
* No recursion beyond one level. Complex packages must use package.json manifest.
* Respects .gitignore, .ignore, and .fdignore files.
*/
export async function discoverExtensionModulePaths(ctx: LoadContext, dir: string): Promise<string[]> {
const discovered: string[] = [];
const entries = await readDirEntries(dir);
// Initialize ignore matcher and read ignore rules from root
const ig = createIgnoreMatcher();
await addIgnoreRules(ig, dir, dir, readFile);
for (const entry of entries) {
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
const entryPath = path.join(dir, entry.name);
// 1. Direct files: *.ts or *.js
if (entry.isFile() && isExtensionModuleFile(entry.name)) {
if ((entry.isFile() || entry.isSymbolicLink()) && isExtensionModuleFile(entry.name)) {
if (shouldIgnore(ig, dir, entryPath, false)) continue;
discovered.push(entryPath);
continue;
}
// 2 & 3. Subdirectories
if (entry.isDirectory()) {
if (entry.isDirectory() || entry.isSymbolicLink()) {
if (shouldIgnore(ig, dir, entryPath, true)) continue;
const subEntries = await readDirEntries(entryPath);
const subFileNames = new Set(subEntries.filter(e => e.isFile()).map(e => e.name));
@@ -7,6 +7,7 @@ import type { SkillsSettings } from "../config/settings";
import type { Skill as CapabilitySkill, SkillFrontmatter as ImportedSkillFrontmatter } from "../discovery";
import { loadCapability } from "../discovery";
import { parseFrontmatter } from "../utils/frontmatter";
import { addIgnoreRules, createIgnoreMatcher, type IgnoreMatcher, shouldIgnore } from "../utils/ignore-files";
// Re-export SkillFrontmatter for backward compatibility
export type { ImportedSkillFrontmatter as SkillFrontmatter };
@@ -38,14 +39,24 @@ export interface LoadSkillsFromDirOptions {
source: string;
}
async function readFileContent(filePath: string): Promise<string | null> {
try {
return await fs.readFile(filePath, "utf-8");
} catch {
return null;
}
}
/**
* Load skills from a directory recursively.
* Skills are directories containing a SKILL.md file with frontmatter including a description.
* Respects .gitignore, .ignore, and .fdignore files.
*/
export async function loadSkillsFromDir(options: LoadSkillsFromDirOptions): Promise<LoadSkillsResult> {
const skills: Skill[] = [];
const warnings: SkillWarning[] = [];
const seenPaths = new Set<string>();
const rootDir = options.dir;
async function addSkill(skillFile: string, skillDir: string, dirName: string): Promise<void> {
if (seenPaths.has(skillFile)) return;
@@ -70,8 +81,11 @@ export async function loadSkillsFromDir(options: LoadSkillsFromDirOptions): Prom
}
}
async function scanDir(dir: string): Promise<void> {
async function scanDir(dir: string, ig: IgnoreMatcher): Promise<void> {
try {
// Add ignore rules from this directory
await addIgnoreRules(ig, dir, rootDir, readFileContent);
// First check if this directory itself is a skill
const selfSkillFile = path.join(dir, "SKILL.md");
try {
@@ -92,8 +106,13 @@ export async function loadSkillsFromDir(options: LoadSkillsFromDirOptions): Prom
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await scanDir(fullPath);
const isDir = entry.isDirectory();
// Check if this entry should be ignored
if (shouldIgnore(ig, rootDir, fullPath, isDir)) continue;
if (isDir) {
await scanDir(fullPath, ig);
}
}
} catch (err) {
@@ -101,7 +120,8 @@ export async function loadSkillsFromDir(options: LoadSkillsFromDirOptions): Prom
}
}
await scanDir(options.dir);
const ig = createIgnoreMatcher();
await scanDir(options.dir, ig);
return { skills, warnings };
}
@@ -109,11 +129,13 @@ export async function loadSkillsFromDir(options: LoadSkillsFromDirOptions): Prom
/**
* Scan a directory for SKILL.md files recursively.
* Used internally by loadSkills for custom directories.
* Respects .gitignore, .ignore, and .fdignore files.
*/
async function scanDirectoryForSkills(dir: string): Promise<LoadSkillsResult> {
const skills: Skill[] = [];
const warnings: SkillWarning[] = [];
const seenPaths = new Set<string>();
const rootDir = dir;
async function addSkill(skillFile: string, skillDir: string, dirName: string): Promise<void> {
if (seenPaths.has(skillFile)) return;
@@ -138,8 +160,11 @@ async function scanDirectoryForSkills(dir: string): Promise<LoadSkillsResult> {
}
}
async function scanDir(currentDir: string): Promise<void> {
async function scanDir(currentDir: string, ig: IgnoreMatcher): Promise<void> {
try {
// Add ignore rules from this directory
await addIgnoreRules(ig, currentDir, rootDir, readFileContent);
// First check if this directory itself is a skill
const selfSkillFile = path.join(currentDir, "SKILL.md");
try {
@@ -160,8 +185,13 @@ async function scanDirectoryForSkills(dir: string): Promise<LoadSkillsResult> {
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
await scanDir(fullPath);
const isDir = entry.isDirectory();
// Check if this entry should be ignored
if (shouldIgnore(ig, rootDir, fullPath, isDir)) continue;
if (isDir) {
await scanDir(fullPath, ig);
}
}
} catch (err) {
@@ -169,7 +199,8 @@ async function scanDirectoryForSkills(dir: string): Promise<LoadSkillsResult> {
}
}
await scanDir(dir);
const ig = createIgnoreMatcher();
await scanDir(dir, ig);
return { skills, warnings };
}
@@ -387,7 +387,7 @@ export class EventController {
const method = settings.get("notifications.onComplete");
if (method === "off") return;
const protocol = method === "auto" ? detectNotificationProtocol() : method;
const title = this.ctx.sessionManager.getSessionTitle();
const title = this.ctx.sessionManager.getSessionName();
const message = title ? `${title}: Complete` : "Complete";
sendNotification(protocol, message);
}
@@ -41,17 +41,20 @@ export class InputController {
this.ctx.isPythonMode = false;
this.ctx.updateEditorBorderColor();
} else if (!this.ctx.editor.getText().trim()) {
// Double-escape with empty editor triggers /tree or /branch based on setting
const now = Date.now();
if (now - this.ctx.lastEscapeTime < 500) {
if (settings.get("doubleEscapeAction") === "tree") {
this.ctx.showTreeSelector();
// Double-escape with empty editor triggers /tree, /branch, or nothing based on setting
const action = settings.get("doubleEscapeAction");
if (action !== "none") {
const now = Date.now();
if (now - this.ctx.lastEscapeTime < 500) {
if (action === "tree") {
this.ctx.showTreeSelector();
} else {
this.ctx.showUserMessageSelector();
}
this.ctx.lastEscapeTime = 0;
} else {
this.ctx.showUserMessageSelector();
this.ctx.lastEscapeTime = now;
}
this.ctx.lastEscapeTime = 0;
} else {
this.ctx.lastEscapeTime = now;
}
}
};
@@ -419,13 +422,13 @@ export class InputController {
// Generate session title on first message
const hasUserMessages = this.ctx.agent.state.messages.some((m: AgentMessage) => m.role === "user");
if (!hasUserMessages && !this.ctx.sessionManager.getSessionTitle() && !process.env.OMP_NO_TITLE) {
if (!hasUserMessages && !this.ctx.sessionManager.getSessionName() && !process.env.OMP_NO_TITLE) {
const registry = this.ctx.session.modelRegistry;
const smolModel = this.ctx.settings.getModelRole("smol");
generateSessionTitle(text, registry, smolModel, this.ctx.session.sessionId)
.then(async title => {
if (title) {
await this.ctx.sessionManager.setSessionTitle(title);
await this.ctx.sessionManager.setSessionName(title);
setTerminalTitle(`π: ${title}`);
}
})
@@ -330,7 +330,7 @@ export class InteractiveMode implements InteractiveModeContext {
}
// Set terminal title if session already has one (resumed session)
const existingTitle = this.sessionManager.getSessionTitle();
const existingTitle = this.sessionManager.getSessionName();
if (existingTitle) {
setTerminalTitle(`pi: ${existingTitle}`);
}
@@ -448,6 +448,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
interruptMode: session.interruptMode,
sessionFile: session.sessionFile,
sessionId: session.sessionId,
sessionName: session.sessionName,
autoCompactionEnabled: session.autoCompactionEnabled,
messageCount: session.messages.length,
queuedMessageCount: session.queuedMessageCount,
@@ -594,6 +595,15 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
return success(id, "get_last_assistant_text", { text });
}
case "set_session_name": {
const name = command.name.trim();
if (!name) {
return error(id, "set_session_name", "Session name cannot be empty");
}
session.setSessionName(name);
return success(id, "set_session_name");
}
// =================================================================
// Messages
// =================================================================
@@ -58,6 +58,7 @@ export type RpcCommand =
| { id?: string; type: "branch"; entryId: string }
| { id?: string; type: "get_branch_messages" }
| { id?: string; type: "get_last_assistant_text" }
| { id?: string; type: "set_session_name"; name: string }
// Messages
| { id?: string; type: "get_messages" };
@@ -76,6 +77,7 @@ export interface RpcSessionState {
interruptMode: "immediate" | "wait";
sessionFile?: string;
sessionId: string;
sessionName?: string;
autoCompactionEnabled: boolean;
messageCount: number;
queuedMessageCount: number;
@@ -166,6 +168,7 @@ export type RpcResponse =
success: true;
data: { text: string | null };
}
| { id?: string; type: "response"; command: "set_session_name"; success: true }
// Messages
| { id?: string; type: "response"; command: "get_messages"; success: true; data: { messages: AgentMessage[] } }
@@ -988,6 +988,11 @@ export class AgentSession {
return this.sessionManager.getSessionId();
}
/** Current session display name, if set */
get sessionName(): string | undefined {
return this.sessionManager.getSessionName();
}
/** Scoped models for cycling (from --models flag) */
get scopedModels(): ReadonlyArray<{ model: Model<any>; thinkingLevel: ThinkingLevel }> {
return this._scopedModels;
@@ -1715,6 +1720,13 @@ export class AgentSession {
return true;
}
/**
* Set a display name for the current session.
*/
setSessionName(name: string): void {
this.sessionManager.setSessionName(name);
}
/**
* Fork the current session, creating a new session file with the exact same state.
* Copies all entries and artifacts to the new session.
@@ -972,7 +972,7 @@ async function collectSessionsFromFiles(files: string[], storage: SessionStorage
export class SessionManager {
private sessionId: string = "";
private sessionTitle: string | undefined;
private sessionName: string | undefined;
private sessionFile: string | undefined;
private sessionDir: string;
private cwd: string;
@@ -1021,7 +1021,7 @@ export class SessionManager {
if (this.fileEntries.length > 0) {
const header = this.fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
this.sessionId = header?.id ?? nanoid();
this.sessionTitle = header?.title;
this.sessionName = header?.title;
if (migrateToCurrentVersion(this.fileEntries)) {
await this._rewriteFile();
@@ -1076,12 +1076,12 @@ export class SessionManager {
type: "session",
version: CURRENT_SESSION_VERSION,
id: this.sessionId,
title: oldHeader?.title ?? this.sessionTitle,
title: oldHeader?.title ?? this.sessionName,
timestamp,
cwd: this.cwd,
parentSession: oldSessionId,
};
this.sessionTitle = newHeader.title;
this.sessionName = newHeader.title;
// Replace the header in fileEntries
const entries = this.fileEntries.filter(e => e.type !== "session") as SessionEntry[];
@@ -1293,17 +1293,17 @@ export class SessionManager {
return this.sessionFile;
}
getSessionTitle(): string | undefined {
return this.sessionTitle;
getSessionName(): string | undefined {
return this.sessionName;
}
async setSessionTitle(title: string): Promise<void> {
this.sessionTitle = title;
async setSessionName(name: string): Promise<void> {
this.sessionName = name;
// Update the in-memory header (so first flush includes title)
const header = this.fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
if (header) {
header.title = title;
header.title = name;
}
// Update the session file header with the title (if already flushed)
@@ -1915,7 +1915,7 @@ export class SessionManager {
const newHeader = manager.fileEntries[0] as SessionHeader;
newHeader.title = sourceHeader?.title;
manager.fileEntries = [newHeader, ...historyEntries];
manager.sessionTitle = newHeader.title;
manager.sessionName = newHeader.title;
manager._buildIndex();
await manager._rewriteFile();
return manager;
@@ -0,0 +1,119 @@
/**
* Ignore file handling for .gitignore/.ignore/.fdignore support when scanning directories.
*/
import * as path from "node:path";
import ignore from "ignore";
export const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"] as const;
export type IgnoreMatcher = ReturnType<typeof ignore>;
/**
* Convert a path to POSIX format (forward slashes).
*/
export function toPosixPath(p: string): string {
return p.split(path.sep).join("/");
}
/**
* Prefix an ignore pattern to make it relative to a subdirectory.
* Returns null for comments and empty lines.
*/
export function prefixIgnorePattern(line: string, prefix: string): string | null {
const trimmed = line.trim();
if (!trimmed) return null;
if (trimmed.startsWith("#") && !trimmed.startsWith("\\#")) return null;
let pattern = line;
let negated = false;
if (pattern.startsWith("!")) {
negated = true;
pattern = pattern.slice(1);
} else if (pattern.startsWith("\\!")) {
pattern = pattern.slice(1);
}
if (pattern.startsWith("/")) {
pattern = pattern.slice(1);
}
const prefixed = prefix ? `${prefix}${pattern}` : pattern;
return negated ? `!${prefixed}` : prefixed;
}
/**
* Read and add ignore rules from a directory to the matcher.
*/
export async function addIgnoreRules(
ig: IgnoreMatcher,
dir: string,
rootDir: string,
readFile: (path: string) => Promise<string | null>,
): Promise<void> {
const relativeDir = path.relative(rootDir, dir);
const prefix = relativeDir ? `${toPosixPath(relativeDir)}/` : "";
for (const filename of IGNORE_FILE_NAMES) {
const ignorePath = path.join(dir, filename);
const content = await readFile(ignorePath);
if (!content) continue;
const patterns = content
.split(/\r?\n/)
.map(line => prefixIgnorePattern(line, prefix))
.filter((line): line is string => Boolean(line));
if (patterns.length > 0) {
ig.add(patterns);
}
}
}
/**
* Read and add ignore rules from a directory to the matcher (synchronous version).
*/
export function addIgnoreRulesSync(
ig: IgnoreMatcher,
dir: string,
rootDir: string,
readFileSync: (path: string) => string | null,
): void {
const relativeDir = path.relative(rootDir, dir);
const prefix = relativeDir ? `${toPosixPath(relativeDir)}/` : "";
for (const filename of IGNORE_FILE_NAMES) {
const ignorePath = path.join(dir, filename);
const content = readFileSync(ignorePath);
if (!content) continue;
const patterns = content
.split(/\r?\n/)
.map(line => prefixIgnorePattern(line, prefix))
.filter((line): line is string => Boolean(line));
if (patterns.length > 0) {
ig.add(patterns);
}
}
}
/**
* Create a fresh ignore matcher.
*/
export function createIgnoreMatcher(): IgnoreMatcher {
return ignore();
}
/**
* Check if a path should be ignored.
* @param ig - The ignore matcher
* @param root - The root directory for relative path calculation
* @param fullPath - The full path to check
* @param isDir - Whether the path is a directory
*/
export function shouldIgnore(ig: IgnoreMatcher, root: string, fullPath: string, isDir: boolean): boolean {
const relPath = toPosixPath(path.relative(root, fullPath));
const ignorePath = isDir ? `${relPath}/` : relPath;
return ig.ignores(ignorePath);
}
+157 -78
View File
@@ -3,6 +3,86 @@ import * as os from "node:os";
import * as path from "node:path";
import { fuzzyFind } from "@oh-my-pi/pi-natives";
const PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]);
function findLastDelimiter(text: string): number {
for (let i = text.length - 1; i >= 0; i -= 1) {
if (PATH_DELIMITERS.has(text[i] ?? "")) {
return i;
}
}
return -1;
}
function findUnclosedQuoteStart(text: string): number | null {
let inQuotes = false;
let quoteStart = -1;
for (let i = 0; i < text.length; i += 1) {
if (text[i] === '"') {
inQuotes = !inQuotes;
if (inQuotes) {
quoteStart = i;
}
}
}
return inQuotes ? quoteStart : null;
}
function isTokenStart(text: string, index: number): boolean {
return index === 0 || PATH_DELIMITERS.has(text[index - 1] ?? "");
}
function extractQuotedPrefix(text: string): string | null {
const quoteStart = findUnclosedQuoteStart(text);
if (quoteStart === null) {
return null;
}
if (quoteStart > 0 && text[quoteStart - 1] === "@") {
if (!isTokenStart(text, quoteStart - 1)) {
return null;
}
return text.slice(quoteStart - 1);
}
if (!isTokenStart(text, quoteStart)) {
return null;
}
return text.slice(quoteStart);
}
function parsePathPrefix(prefix: string): { rawPrefix: string; isAtPrefix: boolean; isQuotedPrefix: boolean } {
if (prefix.startsWith('@"')) {
return { rawPrefix: prefix.slice(2), isAtPrefix: true, isQuotedPrefix: true };
}
if (prefix.startsWith('"')) {
return { rawPrefix: prefix.slice(1), isAtPrefix: false, isQuotedPrefix: true };
}
if (prefix.startsWith("@")) {
return { rawPrefix: prefix.slice(1), isAtPrefix: true, isQuotedPrefix: false };
}
return { rawPrefix: prefix, isAtPrefix: false, isQuotedPrefix: false };
}
function buildCompletionValue(
path: string,
options: { isDirectory: boolean; isAtPrefix: boolean; isQuotedPrefix: boolean },
): string {
const needsQuotes = options.isQuotedPrefix || path.includes(" ");
const prefix = options.isAtPrefix ? "@" : "";
if (!needsQuotes) {
return `${prefix}${path}`;
}
const openQuote = `${prefix}"`;
const closeQuote = options.isDirectory ? "" : '"';
return `${openQuote}${path}${closeQuote}`;
}
/**
* Check if query is a subsequence of target (fuzzy match).
* "wig" matches "skill:wig" because w-i-g appear in order.
@@ -107,23 +187,24 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
const currentLine = lines[cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, cursorCol);
// Check for @ file reference (fuzzy search) - must be after a space or at start
const atMatch = textBeforeCursor.match(/(?:^|[\s])(@[^\s]*)$/);
if (atMatch) {
const prefix = atMatch[1] ?? "@"; // The @... part
const query = prefix.slice(1); // Remove the @
// Check for @ file reference (fuzzy search) - must be after a delimiter or at start
const atPrefix = this.extractAtPrefix(textBeforeCursor);
if (atPrefix) {
const { rawPrefix, isQuotedPrefix } = parsePathPrefix(atPrefix);
const suggestions =
query.length > 0 ? await this.getFuzzyFileSuggestions(query) : await this.getFileSuggestions("@");
if (suggestions.length === 0 && query.length > 0) {
const fallback = await this.getFileSuggestions(prefix);
rawPrefix.length > 0
? await this.getFuzzyFileSuggestions(rawPrefix, { isQuotedPrefix })
: await this.getFileSuggestions("@");
if (suggestions.length === 0 && rawPrefix.length > 0) {
const fallback = await this.getFileSuggestions(atPrefix);
if (fallback.length === 0) return null;
return { items: fallback, prefix };
return { items: fallback, prefix: atPrefix };
}
if (suggestions.length === 0) return null;
return {
items: suggestions,
prefix: prefix,
prefix: atPrefix,
};
}
@@ -290,24 +371,31 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
};
}
// Extract a path-like prefix from the text before cursor
private extractPathPrefix(text: string, forceExtract: boolean = false): string | null {
// Check for @ file attachment syntax first
const atMatch = text.match(/@([^\s]*)$/);
if (atMatch) {
return atMatch[0]; // Return the full @path pattern
// Extract @ prefix for fuzzy file suggestions
private extractAtPrefix(text: string): string | null {
const quotedPrefix = extractQuotedPrefix(text);
if (quotedPrefix?.startsWith('@"')) {
return quotedPrefix;
}
// Simple approach: find the last whitespace/delimiter and extract the word after it
// This avoids catastrophic backtracking from nested quantifiers
const lastDelimiterIndex = Math.max(
text.lastIndexOf(" "),
text.lastIndexOf("\t"),
text.lastIndexOf('"'),
text.lastIndexOf("'"),
text.lastIndexOf("="),
);
const lastDelimiterIndex = findLastDelimiter(text);
const tokenStart = lastDelimiterIndex === -1 ? 0 : lastDelimiterIndex + 1;
if (text[tokenStart] === "@") {
return text.slice(tokenStart);
}
return null;
}
// Extract a path-like prefix from the text before cursor
private extractPathPrefix(text: string, forceExtract: boolean = false): string | null {
const quotedPrefix = extractQuotedPrefix(text);
if (quotedPrefix) {
return quotedPrefix;
}
const lastDelimiterIndex = findLastDelimiter(text);
const pathPrefix = lastDelimiterIndex === -1 ? text : text.slice(lastDelimiterIndex + 1);
// For forced extraction (Tab key), always return something
@@ -379,39 +467,34 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
try {
let searchDir: string;
let searchPrefix: string;
let expandedPrefix = prefix;
let isAtPrefix = false;
// Handle @ file attachment prefix
if (prefix.startsWith("@")) {
isAtPrefix = true;
expandedPrefix = prefix.slice(1); // Remove the @
}
const { rawPrefix, isAtPrefix, isQuotedPrefix } = parsePathPrefix(prefix);
let expandedPrefix = rawPrefix;
// Handle home directory expansion
if (expandedPrefix.startsWith("~")) {
expandedPrefix = this.expandHomePath(expandedPrefix);
}
if (
expandedPrefix === "" ||
expandedPrefix === "./" ||
expandedPrefix === "../" ||
expandedPrefix === "~" ||
expandedPrefix === "~/" ||
expandedPrefix === "/" ||
prefix === "@"
) {
const isRootPrefix =
rawPrefix === "" ||
rawPrefix === "./" ||
rawPrefix === "../" ||
rawPrefix === "~" ||
rawPrefix === "~/" ||
rawPrefix === "/" ||
(isAtPrefix && rawPrefix === "");
if (isRootPrefix) {
// Complete from specified position
if (prefix.startsWith("~") || expandedPrefix === "/") {
if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
searchDir = expandedPrefix;
} else {
searchDir = path.join(this.basePath, expandedPrefix);
}
searchPrefix = "";
} else if (expandedPrefix.endsWith("/")) {
} else if (rawPrefix.endsWith("/")) {
// If prefix ends with /, show contents of that directory
if (prefix.startsWith("~") || expandedPrefix.startsWith("/")) {
if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
searchDir = expandedPrefix;
} else {
searchDir = path.join(this.basePath, expandedPrefix);
@@ -421,7 +504,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
// Split into directory and file prefix
const dir = path.dirname(expandedPrefix);
const file = path.basename(expandedPrefix);
if (prefix.startsWith("~") || expandedPrefix.startsWith("/")) {
if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
searchDir = dir;
} else {
searchDir = path.join(this.basePath, dir);
@@ -451,58 +534,46 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
let relativePath: string;
const name = entry.name;
const displayPrefix = rawPrefix;
// Handle @ prefix path construction
if (isAtPrefix) {
const pathWithoutAt = expandedPrefix;
if (pathWithoutAt.endsWith("/")) {
relativePath = `@${pathWithoutAt}${name}`;
} else if (pathWithoutAt.includes("/")) {
if (pathWithoutAt.startsWith("~/")) {
const homeRelativeDir = pathWithoutAt.slice(2); // Remove ~/
const dir = path.dirname(homeRelativeDir);
relativePath = `@~/${dir === "." ? name : path.join(dir, name)}`;
} else {
relativePath = `@${path.join(path.dirname(pathWithoutAt), name)}`;
}
} else {
if (pathWithoutAt.startsWith("~")) {
relativePath = `@~/${name}`;
} else {
relativePath = `@${name}`;
}
}
} else if (prefix.endsWith("/")) {
if (displayPrefix.endsWith("/")) {
// If prefix ends with /, append entry to the prefix
relativePath = prefix + name;
} else if (prefix.includes("/")) {
relativePath = displayPrefix + name;
} else if (displayPrefix.includes("/")) {
// Preserve ~/ format for home directory paths
if (prefix.startsWith("~/")) {
const homeRelativeDir = prefix.slice(2); // Remove ~/
if (displayPrefix.startsWith("~/")) {
const homeRelativeDir = displayPrefix.slice(2); // Remove ~/
const dir = path.dirname(homeRelativeDir);
relativePath = `~/${dir === "." ? name : path.join(dir, name)}`;
} else if (prefix.startsWith("/")) {
} else if (displayPrefix.startsWith("/")) {
// Absolute path - construct properly
const dir = path.dirname(prefix);
const dir = path.dirname(displayPrefix);
if (dir === "/") {
relativePath = `/${name}`;
} else {
relativePath = `${dir}/${name}`;
}
} else {
relativePath = path.join(path.dirname(prefix), name);
relativePath = path.join(path.dirname(displayPrefix), name);
}
} else {
// For standalone entries, preserve ~/ if original prefix was ~/
if (prefix.startsWith("~")) {
if (displayPrefix.startsWith("~")) {
relativePath = `~/${name}`;
} else {
relativePath = name;
}
}
const pathValue = isDirectory ? `${relativePath}/` : relativePath;
const value = buildCompletionValue(pathValue, {
isDirectory,
isAtPrefix,
isQuotedPrefix,
});
suggestions.push({
value: isDirectory ? `${relativePath}/` : relativePath,
value,
label: name + (isDirectory ? "/" : ""),
});
}
@@ -547,7 +618,10 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
return score;
}
private async getFuzzyFileSuggestions(query: string): Promise<AutocompleteItem[]> {
private async getFuzzyFileSuggestions(
query: string,
options: { isQuotedPrefix: boolean },
): Promise<AutocompleteItem[]> {
try {
const result = await fuzzyFind({
query,
@@ -572,9 +646,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
for (const { path: entryPath, isDirectory } of topEntries) {
const pathWithoutSlash = isDirectory ? entryPath.slice(0, -1) : entryPath;
const entryName = path.basename(pathWithoutSlash);
const value = buildCompletionValue(entryPath, {
isDirectory,
isAtPrefix: true,
isQuotedPrefix: options.isQuotedPrefix,
});
suggestions.push({
value: `@${entryPath}`,
value,
label: entryName + (isDirectory ? "/" : ""),
description: pathWithoutSlash,
});
+66
View File
@@ -1,4 +1,5 @@
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete";
import { getEditorKeybindings } from "../keybindings";
import { matchesKey } from "../keys";
import type { SymbolTheme } from "../symbols";
import { type Component, CURSOR_MARKER, type Focusable } from "../tui";
@@ -283,6 +284,9 @@ export class Editor implements Component, Focusable {
private killRing: string[] = [];
private lastKillWasKillCommand: boolean = false;
// Character jump mode
private jumpMode: "forward" | "backward" | null = null;
// Border color (can be changed dynamically)
public borderColor: (str: string) => string;
@@ -630,6 +634,28 @@ export class Editor implements Component, Focusable {
}
handleInput(data: string): void {
const kb = getEditorKeybindings();
// Handle character jump mode (awaiting next character to jump to)
if (this.jumpMode !== null) {
// Cancel if the hotkey is pressed again
if (kb.matches(data, "jumpForward") || kb.matches(data, "jumpBackward")) {
this.jumpMode = null;
return;
}
if (data.charCodeAt(0) >= 32) {
// Printable character - perform the jump
const direction = this.jumpMode;
this.jumpMode = null;
this.jumpToChar(data, direction);
return;
}
// Control character - cancel and fall through to normal handling
this.jumpMode = null;
}
// Handle bracketed paste mode
// Start of paste: \x1b[200~
// End of paste: \x1b[201~
@@ -960,6 +986,12 @@ export class Editor implements Component, Focusable {
else if (matchesKey(data, "shift+space")) {
this.insertCharacter(" ");
}
// Character jump mode triggers
else if (kb.matches(data, "jumpForward")) {
this.jumpMode = "forward";
} else if (kb.matches(data, "jumpBackward")) {
this.jumpMode = "backward";
}
// Kitty CSI-u printable characters (shifted symbols like @, ?, {, })
else {
const kittyChar = decodeKittyPrintable(data);
@@ -1774,6 +1806,40 @@ export class Editor implements Component, Focusable {
this.state.cursorCol = newCol;
}
/**
* Jump to the first occurrence of a character in the specified direction.
* Multi-line search. Case-sensitive. Skips the current cursor position.
*/
private jumpToChar(char: string, direction: "forward" | "backward"): void {
this.resetKillSequence();
const isForward = direction === "forward";
const lines = this.state.lines;
const end = isForward ? lines.length : -1;
const step = isForward ? 1 : -1;
for (let lineIdx = this.state.cursorLine; lineIdx !== end; lineIdx += step) {
const line = lines[lineIdx] || "";
const isCurrentLine = lineIdx === this.state.cursorLine;
// Current line: start after/before cursor; other lines: search full line
const searchFrom = isCurrentLine
? isForward
? this.state.cursorCol + 1
: this.state.cursorCol - 1
: undefined;
const idx = isForward ? line.indexOf(char, searchFrom) : line.lastIndexOf(char, searchFrom);
if (idx !== -1) {
this.state.cursorLine = lineIdx;
this.state.cursorCol = idx;
return;
}
}
// No match found - cursor stays in place
}
private moveWordForwards(): void {
const currentLine = this.state.lines[this.state.cursorLine] || "";
+6 -2
View File
@@ -13,6 +13,8 @@ export type EditorAction =
| "cursorWordRight"
| "cursorLineStart"
| "cursorLineEnd"
| "jumpForward"
| "jumpBackward"
// Deletion
| "deleteCharBackward"
| "deleteCharForward"
@@ -51,12 +53,14 @@ export const DEFAULT_EDITOR_KEYBINDINGS: Required<EditorKeybindingsConfig> = {
// Cursor movement
cursorUp: "up",
cursorDown: "down",
cursorLeft: "left",
cursorRight: "right",
cursorLeft: ["left", "ctrl+b"],
cursorRight: ["right", "ctrl+f"],
cursorWordLeft: ["alt+left", "ctrl+left", "alt+b"],
cursorWordRight: ["alt+right", "ctrl+right", "alt+f"],
cursorLineStart: ["home", "ctrl+a"],
cursorLineEnd: ["end", "ctrl+e"],
jumpForward: "ctrl+]",
jumpBackward: "ctrl+alt+]",
// Deletion
deleteCharBackward: "backspace",
deleteCharForward: ["delete", "ctrl+d"],