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:
@@ -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=="],
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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] || "";
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user