diff --git a/bun.lock b/bun.lock index 21946f1b0..974d6c3b9 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/docs/porting-from-pi-mono.md b/docs/porting-from-pi-mono.md index c9bffb7f6..35597e223 100644 --- a/docs/porting-from-pi-mono.md +++ b/docs/porting-from-pi-mono.md @@ -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 (..) + +packages/: +- : +- : (# by @) + +packages/: +- : +``` + +**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 | diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 9aa105dd1..a59354c81 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -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, + arguments: (event.content_block.input as Record) ?? {}, 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 ?? {}, }); } } diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 11bd2b390..0b3433fea 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -53,8 +53,9 @@ function normalizeMistralToolId(id: string, isMistral: boolean): string { return normalized; } -type ResolvedOpenAICompat = Required> & { +type ResolvedOpenAICompat = Required> & { 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 = {}; + 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, }; } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 97035ed2c..da15ea73c 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -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 { id: string; diff --git a/packages/ai/test/openai-completions-tool-result-images.test.ts b/packages/ai/test/openai-completions-tool-result-images.test.ts index b4cf7e825..e89f7c688 100644 --- a/packages/ai/test/openai-completions-tool-result-images.test.ts +++ b/packages/ai/test/openai-completions-tool-result-images.test.ts @@ -28,6 +28,7 @@ const compat: Required = { requiresReasoningContentForToolCalls: false, requiresAssistantContentForToolCalls: false, openRouterRouting: {}, + vercelGatewayRouting: {}, }; function buildToolResult(toolCallId: string, timestamp: number): ToolResultMessage { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 99657cfd6..58c1a7928 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 4233564ea..1f17daf39 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -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", diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 37fccbbe1..7976f392f 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -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 diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 1cdb31791..2c8044c08 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -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", diff --git a/packages/coding-agent/src/discovery/helpers.ts b/packages/coding-agent/src/discovery/helpers.ts index eb0cfaf5d..fce187384 100644 --- a/packages/coding-agent/src/discovery/helpers.ts +++ b/packages/coding-agent/src/discovery/helpers.ts @@ -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(obj: T, extraEnv?: Record): /** * Load files from a directory matching a pattern. + * Respects .gitignore, .ignore, and .fdignore files. */ export async function loadFilesFromDir( _ctx: LoadContext, @@ -324,24 +337,47 @@ export async function loadFilesFromDir( 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> { + 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//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 { 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)); diff --git a/packages/coding-agent/src/extensibility/skills.ts b/packages/coding-agent/src/extensibility/skills.ts index 727ae6a91..77223ef69 100644 --- a/packages/coding-agent/src/extensibility/skills.ts +++ b/packages/coding-agent/src/extensibility/skills.ts @@ -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 { + 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 { const skills: Skill[] = []; const warnings: SkillWarning[] = []; const seenPaths = new Set(); + const rootDir = options.dir; async function addSkill(skillFile: string, skillDir: string, dirName: string): Promise { if (seenPaths.has(skillFile)) return; @@ -70,8 +81,11 @@ export async function loadSkillsFromDir(options: LoadSkillsFromDirOptions): Prom } } - async function scanDir(dir: string): Promise { + async function scanDir(dir: string, ig: IgnoreMatcher): Promise { 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 { const skills: Skill[] = []; const warnings: SkillWarning[] = []; const seenPaths = new Set(); + const rootDir = dir; async function addSkill(skillFile: string, skillDir: string, dirName: string): Promise { if (seenPaths.has(skillFile)) return; @@ -138,8 +160,11 @@ async function scanDirectoryForSkills(dir: string): Promise { } } - async function scanDir(currentDir: string): Promise { + async function scanDir(currentDir: string, ig: IgnoreMatcher): Promise { 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 { 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 { } } - await scanDir(dir); + const ig = createIgnoreMatcher(); + await scanDir(dir, ig); return { skills, warnings }; } diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index e90e9cec9..307fc60db 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -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); } diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index daa6bdf3f..ec65e8c9d 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -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}`); } }) diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index bf0c2aa74..be45b7775 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -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}`); } diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index e7034f08d..27a40576b 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -448,6 +448,7 @@ export async function runRpcMode(session: AgentSession): Promise { 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 { 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 // ================================================================= diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 786ecd979..e374e5765 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -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[] } } diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 4f3543fe2..31d26d24d 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -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; 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. diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 91b249496..16428cdd7 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -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 { - this.sessionTitle = title; + async setSessionName(name: string): Promise { + 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; diff --git a/packages/coding-agent/src/utils/ignore-files.ts b/packages/coding-agent/src/utils/ignore-files.ts new file mode 100644 index 000000000..0a4913449 --- /dev/null +++ b/packages/coding-agent/src/utils/ignore-files.ts @@ -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; + +/** + * 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, +): Promise { + 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); +} diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index 3d5f13ae7..c962c3648 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -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 { + private async getFuzzyFileSuggestions( + query: string, + options: { isQuotedPrefix: boolean }, + ): Promise { 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, }); diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index fb3a151fe..16eaa1ec6 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -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] || ""; diff --git a/packages/tui/src/keybindings.ts b/packages/tui/src/keybindings.ts index ddac3689d..a2fdab378 100644 --- a/packages/tui/src/keybindings.ts +++ b/packages/tui/src/keybindings.ts @@ -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 = { // 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"],