// @generated by scripts/build-runtime.mjs; do not edit. // @ts-nocheck -- generated JavaScript uses a .ts extension for Pi's Jiti loader. // src/file-context.ts import { createHash } from "node:crypto"; import { constants as constants2 } from "node:fs"; import { lstat as lstat2, open as open2, readdir, realpath as realpath3 } from "node:fs/promises"; import { isAbsolute as isAbsolute3, relative as relative3, resolve as resolve3, sep as sep3 } from "node:path"; // src/external-editor.ts import { spawn } from "node:child_process"; import { lstat, realpath } from "node:fs/promises"; import { isAbsolute, relative, resolve, sep } from "node:path"; import { getAgentDir, SettingsManager, withFileMutationQueue } from "@earendil-works/pi-coding-agent"; async function editProjectFileInExternalEditor(options) { options.signal?.throwIfAborted(); const target = await resolveEditableProjectFile(options.root, options.projectPath, options.signal); options.signal?.throwIfAborted(); const command = options.command ?? SettingsManager.create(options.root, options.agentDir ?? getAgentDir(), { projectTrusted: options.projectTrusted }).getExternalEditorCommand(); const [editor, ...editorArgs] = command.trim().split(/\s+/u); if (!editor) throw new Error("External editor command is empty"); assertEditorTargetSafeForPlatform(target); await withFileMutationQueue(target, async () => { options.signal?.throwIfAborted(); let stopped = false; try { options.tui.stop(); stopped = true; process.stdout.write("Launching external editor. Pi will resume when the editor exits.\n"); await runExternalEditorProcess(editor, [...editorArgs, target], options.signal, options.spawnProcess); options.signal?.throwIfAborted(); } finally { if (stopped && !options.signal?.aborted && (options.isCurrent === void 0 || options.isCurrent())) { options.tui.start(); options.tui.requestRender(true); } } }); } async function resolveEditableProjectFile(root, projectPath, signal) { signal?.throwIfAborted(); if (!projectPath || isAbsolute(projectPath) || projectPath.includes("\0")) { throw new Error("File path is outside the project"); } const canonicalRoot = await realpath(root); signal?.throwIfAborted(); const candidate = resolve(canonicalRoot, projectPath); if (!isInside(canonicalRoot, candidate)) throw new Error("File path is outside the project"); const candidateInfo = await lstat(candidate); signal?.throwIfAborted(); if (candidateInfo.isSymbolicLink()) throw new Error(`${projectPath} is a symbolic link`); const canonicalFile = await realpath(candidate); signal?.throwIfAborted(); if (!isInside(canonicalRoot, canonicalFile)) throw new Error("File path is outside the project"); const info = await lstat(canonicalFile); signal?.throwIfAborted(); if (!info.isFile()) throw new Error(`${projectPath} is not a regular file`); return canonicalFile; } async function runExternalEditorProcess(command, args, signal, spawnProcess = defaultSpawnExternalEditorProcess) { signal?.throwIfAborted(); const child = spawnProcess(command, args); await new Promise((resolvePromise, reject) => { let settled = false; let aborted = signal?.aborted ?? false; const finish = (error) => { if (settled) return; settled = true; signal?.removeEventListener("abort", abort); if (error) reject(error); else resolvePromise(); }; const abort = () => { aborted = true; if (!child.killed) child.kill(); }; child.once("error", (error) => finish(aborted ? abortError() : error)); child.once("close", (code) => { if (aborted) { finish(abortError()); return; } if (code === 0) { finish(); return; } finish(new Error(`External editor exited with code ${code ?? "unknown"}`)); }); signal?.addEventListener("abort", abort, { once: true }); if (signal?.aborted) abort(); }); } function assertEditorTargetSafeForPlatform(target, platform = process.platform) { if (platform === "win32" && /[\r\n"&|<>^()%!]/u.test(target)) { throw new Error("File path contains characters unsafe for the Windows external-editor shell"); } } function defaultSpawnExternalEditorProcess(command, args) { return spawn(command, [...args], { stdio: "inherit", shell: process.platform === "win32" }); } function isInside(root, candidate) { const result = relative(root, candidate); return result === "" || !result.startsWith(`..${sep}`) && result !== ".." && !isAbsolute(result); } function abortError() { return new DOMException("External editor cancelled", "AbortError"); } // src/file-context-explorer.ts import { Input as Input2, Key as Key2, matchesKey as matchesKey2, truncateToWidth as truncateToWidth4, visibleWidth as visibleWidth3 } from "@earendil-works/pi-tui"; // src/content-search-session.ts import { Input, Key, matchesKey, visibleWidth as visibleWidth2 } from "@earendil-works/pi-tui"; // src/content-search.ts var MAX_CONTENT_QUERY_LENGTH = 256; var DEFAULT_MAX_RESULTS = 100; async function searchProjectContents(files, loadFile, query, options = {}) { const signal = options.signal; signal?.throwIfAborted(); if (!query.trim() || query.length > MAX_CONTENT_QUERY_LENGTH) { return { matches: [], truncated: false, skippedFiles: 0 }; } const maxResults = Math.max(1, options.maxResults ?? DEFAULT_MAX_RESULTS); const caseSensitive = options.caseSensitive ?? false; const literalExpression = new RegExp(escapeRegExp(query), caseSensitive ? "g" : "gi"); const fuzzyQuery = options.fuzzy ? indexedCharacters(query, caseSensitive) : []; const matches = []; let truncated = false; let skippedFiles = 0; for (const path of files) { signal?.throwIfAborted(); let file; try { file = await loadFile(path, signal); } catch (error) { if (isAbortError(error) || signal?.aborted) throw error; skippedFiles += 1; continue; } signal?.throwIfAborted(); for (let lineIndex = 0; lineIndex < file.lines.length; lineIndex += 1) { const line = file.lines[lineIndex] ?? ""; const literalRanges = findLiteralRanges(line, literalExpression); if (literalRanges.length > 0) { if (matches.length < maxResults) { matches.push({ path: file.path, lineNumber: lineIndex + 1, line, ranges: literalRanges, fuzzy: false }); } else { truncated = true; } continue; } if (!options.fuzzy) continue; const fuzzyRanges = findSubsequenceRanges(line, fuzzyQuery, caseSensitive); if (!fuzzyRanges) continue; if (matches.length < maxResults) { matches.push({ path: file.path, lineNumber: lineIndex + 1, line, ranges: fuzzyRanges, fuzzy: true }); } else { truncated = true; } } } return { matches, truncated, skippedFiles }; } function findLiteralRanges(line, expression) { const ranges = []; expression.lastIndex = 0; for (let match = expression.exec(line); match; match = expression.exec(line)) { const start = match.index; ranges.push({ start, end: start + match[0].length }); } return ranges; } function findSubsequenceRanges(line, queryCharacters, caseSensitive) { const lineCharacters = indexedCharacters(line, caseSensitive); const positions = []; let queryIndex = 0; for (let lineIndex = 0; lineIndex < lineCharacters.length && queryIndex < queryCharacters.length; lineIndex += 1) { const lineCharacter = lineCharacters[lineIndex]; const queryCharacter = queryCharacters[queryIndex]; if (!lineCharacter || !queryCharacter || lineCharacter.comparable !== queryCharacter.comparable) { continue; } positions.push({ start: lineCharacter.start, end: lineCharacter.end }); queryIndex += 1; } if (queryIndex !== queryCharacters.length) return void 0; const ranges = []; for (const position of positions) { const previous = ranges.at(-1); if (previous?.end === position.start) previous.end = position.end; else ranges.push(position); } return ranges; } function indexedCharacters(value, caseSensitive) { const characters = []; for (let start = 0; start < value.length; ) { const codePoint = value.codePointAt(start); if (codePoint === void 0) break; const character = String.fromCodePoint(codePoint); const end = start + character.length; characters.push({ start, end, comparable: caseSensitive ? character : character.toLowerCase() }); start = end; } return characters; } function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function isAbortError(error) { return error instanceof Error && error.name === "AbortError"; } // src/content-search-ui.ts import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; var CONTENT_SEARCH_CARD_ROWS = 3; function contentSearchCardCapacity(availableRows) { return Math.max(1, Math.floor((availableRows - 4) / CONTENT_SEARCH_CARD_ROWS)); } function renderContentSearchScreen(options) { const { theme, width, availableRows } = options; const capacity = contentSearchCardCapacity(availableRows); const cwdLabel = options.cwd ? ` \xB7 cwd: ${escapeTerminalControls(options.cwd)}` : ""; const title = theme.fg("accent", theme.bold(`File Context \xB7 Content Search${cwdLabel}`)); const modes = theme.fg( "muted", `Case: ${options.caseSensitive ? "on" : "off"} \xB7 Fuzzy: ${options.fuzzy ? "on" : "off"} \xB7 Alt+C case \xB7 Alt+F fuzzy` ); const body = []; if (options.error) { body.push(theme.fg("error", ` Search failed: ${escapeTerminalControls(options.error)}`)); } else if (options.loading) { body.push(theme.fg("warning", " Searching\u2026")); } else if (!options.query.trim()) { body.push(theme.fg("muted", " Type text to search project contents")); } else if (options.matches.length === 0) { body.push( theme.fg( "muted", ` No matches for "${escapeTerminalControls(options.query)}"${options.fuzzy ? "" : " \xB7 Alt+F enables fuzzy matching"}` ) ); } else { const visible = options.matches.slice(options.scrollOffset, options.scrollOffset + capacity); for (let visibleIndex = 0; visibleIndex < visible.length; visibleIndex += 1) { const index = options.scrollOffset + visibleIndex; const match = visible[visibleIndex]; if (match) { body.push(...renderContentSearchCard(match, index === options.selectedIndex, width, theme)); } } } const count = `${options.matches.length}${options.truncated ? "+" : ""} matches`; const skipped = options.skippedFiles > 0 ? ` \xB7 ${options.skippedFiles} skipped` : ""; const action = options.opening ? "Opening\u2026" : `${count}${skipped} \xB7 \u2191\u2193 navigate \xB7 Enter preview \xB7 Tab reference \xB7 Ctrl+F files \xB7 Esc cancel`; return fitRows( [ truncateToWidth(title, width, ""), truncateToWidth(options.queryLine, width, ""), truncateToWidth(modes, width, ""), ...body.map((line) => truncateToWidth(line, width, "")), truncateToWidth(theme.fg(options.opening ? "warning" : "muted", action), width, "") ], availableRows ); } function highlightContentRanges(line, ranges, theme) { let result = ""; let index = 0; for (const range of ranges) { const start = Math.max(index, Math.min(line.length, range.start)); const end = Math.max(start, Math.min(line.length, range.end)); result += escapeTerminalControls(line.slice(index, start)); result += theme.fg("warning", theme.bold(escapeTerminalControls(line.slice(start, end)))); index = end; } return result + escapeTerminalControls(line.slice(index)); } function renderContentSearchCard(match, selected, width, theme) { if (width < 8) { return [truncateToWidth(`${selected ? ">" : " "}${escapeTerminalControls(match.path)}`, width, "")]; } const prefix = selected ? theme.fg("accent", "> ") : " "; const cardWidth = Math.max(4, width - visibleWidth(prefix)); const innerWidth = Math.max(1, cardWidth - 2); const borderColor = selected ? "borderAccent" : "borderMuted"; const border = (text) => theme.fg(borderColor, text); const rawTitle = ` ${escapeTerminalControls(match.path)} \xB7 L${match.lineNumber} `; const title = truncateToWidth(rawTitle, innerWidth, ""); const titleFill = "\u2500".repeat(Math.max(0, innerWidth - visibleWidth(title))); const context = clipMatchContext(match, Math.max(1, innerWidth - 1)); const highlighted = highlightContentRanges(context.line, context.ranges, theme); const content = truncateToWidth(` ${highlighted}`, innerWidth, ""); const contentFill = " ".repeat(Math.max(0, innerWidth - visibleWidth(content))); const contentLine = `${border("\u2502")}${content}${contentFill}${border("\u2502")}`; return [ `${prefix}${border("\u256D")}${title}${border(`${titleFill}\u256E`)}`, `${" ".repeat(visibleWidth(prefix))}${selected ? theme.bg("selectedBg", contentLine) : contentLine}`, `${" ".repeat(visibleWidth(prefix))}${border(`\u2570${"\u2500".repeat(innerWidth)}\u256F`)}` ]; } function clipMatchContext(match, maxCharacters) { const firstStart = match.ranges[0]?.start ?? 0; const lastEnd = match.ranges.at(-1)?.end ?? firstStart; const matchSpan = Math.max(1, lastEnd - firstStart); const before = Math.max(0, Math.floor((maxCharacters - Math.min(matchSpan, maxCharacters)) / 3)); const rawStart = Math.max(0, firstStart - before); const prefix = rawStart > 0 ? "\u2026" : ""; const availableRawCharacters = Math.max(1, maxCharacters - prefix.length - 1); let rawEnd = Math.min(match.line.length, rawStart + availableRawCharacters); if (rawEnd < lastEnd) rawEnd = Math.min(match.line.length, lastEnd); const suffix = rawEnd < match.line.length ? "\u2026" : ""; const line = `${prefix}${match.line.slice(rawStart, rawEnd)}${suffix}`; const ranges = match.ranges.flatMap((range) => { const start = Math.max(rawStart, range.start); const end = Math.min(rawEnd, range.end); return end > start ? [{ start: prefix.length + start - rawStart, end: prefix.length + end - rawStart }] : []; }); return { line, ranges }; } function fitRows(lines, height) { if (lines.length <= height) return lines; if (height <= 1) return lines.slice(0, 1); return [...lines.slice(0, height - 1), lines.at(-1) ?? ""]; } function escapeTerminalControls(text) { return [...text].map((character) => { if (character === " ") return " "; const code = character.charCodeAt(0); if (code <= 31 || code >= 127 && code <= 159) { return `\\x${code.toString(16).padStart(2, "0")}`; } return character; }).join(""); } // src/content-search-session.ts var ContentSearchSession = class { constructor(options) { this.options = options; } options; input = new Input(); matches = []; selectedIndex = 0; scrollOffset = 0; caseSensitive = false; fuzzy = false; truncated = false; skippedFiles = 0; loading = false; request = 0; controller; error; disposed = false; set focused(value) { this.input.focused = value; } activate() { this.disposed = false; if (this.input.getValue().trim()) this.startSearch(); } deactivate() { this.focused = false; this.cancelSearch(); } render(width, availableRows, opening, externalError) { const capacity = contentSearchCardCapacity(availableRows); this.keepSelectionVisible(capacity); const queryLabel = this.options.theme.fg("muted", "Search: "); const queryWidth = Math.max(1, width - visibleWidth2(queryLabel)); const queryLine = `${queryLabel}${this.input.render(queryWidth)[0] ?? ""}`; return renderContentSearchScreen({ theme: this.options.theme, width, availableRows, queryLine, query: this.input.getValue(), cwd: this.options.cwd, matches: this.matches, selectedIndex: this.selectedIndex, scrollOffset: this.scrollOffset, caseSensitive: this.caseSensitive, fuzzy: this.fuzzy, loading: this.loading, opening, truncated: this.truncated, skippedFiles: this.skippedFiles, error: externalError ?? this.error }); } handleInput(data) { if (this.disposed) return; if (matchesKey(data, Key.ctrl("f"))) { this.options.onSwitchFiles(); return; } if (matchesKey(data, Key.escape)) { this.options.onCancel(); return; } if (matchesKey(data, Key.alt("c"))) { this.caseSensitive = !this.caseSensitive; this.startSearch(); return; } if (matchesKey(data, Key.alt("f"))) { this.fuzzy = !this.fuzzy; this.startSearch(); return; } if (this.options.keybindings.matches(data, "tui.select.up")) { this.selectedIndex = Math.max(0, this.selectedIndex - 1); return; } if (this.options.keybindings.matches(data, "tui.select.down")) { this.selectedIndex = Math.min(Math.max(0, this.matches.length - 1), this.selectedIndex + 1); return; } if (this.options.keybindings.matches(data, "tui.select.pageUp")) { this.selectedIndex = Math.max(0, this.selectedIndex - 10); return; } if (this.options.keybindings.matches(data, "tui.select.pageDown")) { this.selectedIndex = Math.min(Math.max(0, this.matches.length - 1), this.selectedIndex + 10); return; } if (this.options.keybindings.matches(data, "tui.select.confirm")) { const match = this.matches[this.selectedIndex]; if (match) this.options.onPreview(match); return; } if (this.options.keybindings.matches(data, "tui.input.tab")) { const match = this.matches[this.selectedIndex]; if (match) this.options.onReference(match.path); return; } const previousQuery = this.input.getValue(); this.input.handleInput(data); if (this.input.getValue() !== previousQuery) this.startSearch(); } invalidate() { this.input.invalidate(); } dispose() { if (this.disposed) return; this.disposed = true; this.cancelSearch(); } startSearch() { this.cancelSearch(); const query = this.input.getValue(); this.matches = []; this.selectedIndex = 0; this.scrollOffset = 0; this.truncated = false; this.skippedFiles = 0; this.error = void 0; if (!query.trim()) return; const request = this.request; const controller = new AbortController(); this.controller = controller; this.loading = true; void searchProjectContents(this.options.files, this.options.loadFile, query, { caseSensitive: this.caseSensitive, fuzzy: this.fuzzy, signal: controller.signal }).then((result) => { if (!this.isCurrent(request, controller)) return; this.matches = result.matches; this.truncated = result.truncated; this.skippedFiles = result.skippedFiles; }).catch((error) => { if (this.isCurrent(request, controller) && !isAbortError2(error)) { this.error = formatError(error); } }).finally(() => { if (request === this.request) { this.loading = false; this.controller = void 0; } if (!this.disposed) this.options.tui.requestRender(); }); } cancelSearch() { this.request += 1; this.controller?.abort(); this.controller = void 0; this.loading = false; } isCurrent(request, controller) { return !this.disposed && request === this.request && this.controller === controller && !controller.signal.aborted; } keepSelectionVisible(capacity) { if (this.selectedIndex < this.scrollOffset) this.scrollOffset = this.selectedIndex; if (this.selectedIndex >= this.scrollOffset + capacity) { this.scrollOffset = this.selectedIndex - capacity + 1; } } }; function isAbortError2(error) { return error instanceof Error && error.name === "AbortError"; } function formatError(error) { return error instanceof Error ? error.message : String(error); } // src/file-browser.ts var ProjectFileBrowser = class { files; filesByPath; constructor(paths) { this.files = paths.map((path) => { const displayPath = safeTerminalText(path); return { path, displayPath, segments: displayPath.split("/") }; }); this.filesByPath = new Map(this.files.map((file) => [file.path, file])); } list(directory) { const directorySegments = directory ? directory.split("/") : []; const directories = /* @__PURE__ */ new Map(); const files = []; for (const file of this.files) { if (!startsWithSegments(file.segments, directorySegments)) continue; const childIndex = directorySegments.length; const label = file.segments[childIndex]; if (!label) continue; if (file.segments.length > childIndex + 1) { const path = [...directorySegments, label].join("/"); directories.set(path, { kind: "directory", path, label }); continue; } files.push({ kind: "file", path: file.path, label }); } return [...[...directories.values()].sort(compareBrowserItems), ...files.sort(compareBrowserItems)]; } searchResults(paths) { return paths.flatMap((path) => { const file = this.filesByPath.get(path); return file ? [{ kind: "file", path: file.path, label: file.displayPath }] : []; }); } }; function parentProjectDirectory(directory) { const separator = directory.lastIndexOf("/"); return separator < 0 ? "" : directory.slice(0, separator); } function safeTerminalText(text) { return [...text].map((character) => { if (character === " ") return " "; const code = character.charCodeAt(0); if (code <= 31 || code >= 127 && code <= 159) { return `\\x${code.toString(16).padStart(2, "0")}`; } return character; }).join(""); } function startsWithSegments(path, prefix) { return prefix.every((segment, index) => path[index] === segment); } function compareBrowserItems(left, right) { return left.label < right.label ? -1 : left.label > right.label ? 1 : 0; } // src/file-browser-ui.ts import { truncateToWidth as truncateToWidth2 } from "@earendil-works/pi-tui"; function renderFileBrowser(options) { const location = options.currentDirectory ? `/${options.currentDirectory}` : "/"; const title = options.theme.fg( "accent", options.theme.bold(`File Context \xB7 files \xB7 ${location}${options.repositoryLabel}`) ); const visibleItems = options.items.slice(options.scrollOffset, options.scrollOffset + options.height); const itemLines = visibleItems.map((item, visibleIndex) => { const index = options.scrollOffset + visibleIndex; const prefix = index === options.selectedIndex ? "> " : " "; const status = item.kind === "file" ? options.statuses?.get(item.path)?.code ?? " " : " "; const suffix = item.kind === "directory" ? "/" : ""; const line = `${prefix}${status} ${item.label}${suffix}`; return truncateToWidth2( index === options.selectedIndex ? options.theme.bg("selectedBg", options.theme.fg("text", line)) : line, options.width, "" ); }); if (itemLines.length === 0) { itemLines.push(truncateToWidth2(options.theme.fg("muted", " No matching files"), options.width, "")); } const state = options.loading ? options.theme.fg("warning", "Loading\u2026") : options.error ? options.theme.fg("error", safeTerminalText(options.error)) : options.theme.fg("muted", formatFileBrowserHint(options)); return [ truncateToWidth2(title, options.width, ""), truncateToWidth2(options.searchLine, options.width, ""), ...itemLines, truncateToWidth2(state, options.width, "") ]; } function formatFileBrowserHint(options) { const selected = options.items[options.selectedIndex]; const countLabel = options.searchActive ? `${options.items.length} matching ${options.items.length === 1 ? "file" : "files"}` : `${options.items.length} ${options.items.length === 1 ? "item" : "items"}`; const navigation = navigationHint(options.keybindings); const confirm = bindingHint(options.keybindings, "tui.select.confirm", ["enter"]); const tab = bindingHint(options.keybindings, "tui.input.tab", ["tab"]); const cancel = uniqueKeys([ ...bindingKeys(options.keybindings, "tui.select.cancel", ["escape"]), formatHintKey("ctrl+c") ]).join("/"); return [ countLabel, `${navigation} navigate`, `${confirm} ${selected?.kind === "directory" ? "open folder" : "preview"}`, ...selected?.kind === "file" ? [`${tab} reference`] : [], "Ctrl+F contents", `${cancel} ${options.currentDirectory && !options.searchActive ? "back" : "cancel"}` ].join(" \xB7 "); } function navigationHint(keybindings) { const up = bindingKeys(keybindings, "tui.select.up", ["up"]); const down = bindingKeys(keybindings, "tui.select.down", ["down"]); if (up.includes("\u2191") && down.includes("\u2193")) { return uniqueKeys(["\u2191\u2193", ...up.filter((key) => key !== "\u2191"), ...down.filter((key) => key !== "\u2193")]).join("/"); } return uniqueKeys([...up, ...down]).join("/"); } function bindingHint(keybindings, binding, fallback) { return bindingKeys(keybindings, binding, fallback).join("/"); } function bindingKeys(keybindings, binding, fallback) { const getKeys = keybindings.getKeys?.bind(keybindings); const keys = getKeys?.(binding) ?? fallback; return uniqueKeys(keys.map(formatHintKey)); } function uniqueKeys(keys) { return [...new Set(keys.filter(Boolean))]; } function formatHintKey(key) { const normalized = safeTerminalText(key).toLowerCase(); if (normalized === "up") return "\u2191"; if (normalized === "down") return "\u2193"; if (normalized === "left") return "\u2190"; if (normalized === "right") return "\u2192"; if (normalized === "return" || normalized === "enter") return "Enter"; if (normalized === "escape" || normalized === "esc") return "Esc"; if (normalized === "tab") return "Tab"; return normalized.split("+").map( (part, index) => part === "ctrl" || part === "alt" || part === "shift" || index > 0 && part.length === 1 ? `${part[0]?.toUpperCase()}${part.slice(1)}` : part ).join("+"); } // src/file-context-preview-ui.ts import { truncateToWidth as truncateToWidth3 } from "@earendil-works/pi-tui"; function renderFilePreview(options) { const { file, theme, width } = options; const range = selectionRange(options.anchor, options.cursor); const selectedText = file.lines.slice(range.start, range.end + 1).join("\n"); const selectedBytes = Buffer.byteLength(selectedText, "utf8"); const estimatedTokens = Math.max(1, Math.ceil(selectedBytes / 4)); const externalEditorKey = bindingHint(options.keybindings, "app.editor.external", []); const footer = options.error ? { lines: [escapeTerminalControls2(options.error)], primaryIndexes: [0] } : previewFooterLines( width, range.start + 1, range.end + 1, estimatedTokens, selectedBytes, options.selectedContext, options.canContinue, options.canEdit ? externalEditorKey : "" ); const previewHeight = Math.max(1, options.availableRows - 1 - (options.blame ? 1 : 0) - footer.lines.length); const scrollOffset = visiblePreviewOffset(options.scrollOffset, options.cursor, previewHeight); const digits = String(Math.max(1, file.lines.length)).length; const changedLines = new Set(options.fileGit?.hunks.flatMap((hunk) => hunk.changedLines) ?? []); const deletedAtLines = new Set( (options.fileGit?.hunks ?? []).filter((hunk) => hunk.changedLines.length === 0 && hunk.oldCount > 0).map((hunk) => Math.max(1, hunk.newStart)) ); const previewLines = file.lines.slice(scrollOffset, scrollOffset + previewHeight).map((rawLine, visibleIndex) => { const index = scrollOffset + visibleIndex; const selected = index >= range.start && index <= range.end; const cursor = index === options.cursor ? ">" : " "; const marker = changedLines.has(index + 1) ? "+" : deletedAtLines.has(index + 1) ? "-" : " "; const number = String(index + 1).padStart(digits, " "); const contentMatch = options.contentMatch?.path === file.path && options.contentMatch.lineNumber === index + 1 && options.contentMatch.line === rawLine ? options.contentMatch : void 0; const content = contentMatch ? highlightContentRanges(rawLine, contentMatch.ranges, theme) : escapeTerminalControls2(rawLine); const line = `${cursor}${marker}${number} \u2502 ${content}`; const styled = selected ? theme.bg("selectedBg", theme.fg("text", line)) : index === options.cursor ? theme.fg("accent", line) : line; return truncateToWidth3(styled, width, ""); }); if (previewLines.length === 0) { previewLines.push(truncateToWidth3(theme.fg("muted", " Empty file"), width, "")); } const gitLabel = options.revision ? `${escapeTerminalControls2(options.revision.revision)}@${options.revision.commit.slice(0, 12)} \xB7 historical` : options.project ? `${escapeTerminalControls2(options.project.branch)}@${options.project.head.slice(0, 12)}${options.project.dirty ? " \xB7 dirty" : ""}${options.fileGit?.status ? ` \xB7 ${options.fileGit.status.label}` : " \xB7 clean"}` : ""; const blameLabel = options.blame ? `L${options.cursor + 1} \xB7 ${options.blame.committed ? options.blame.commit.slice(0, 12) : "uncommitted"} \xB7 ${escapeTerminalControls2(options.blame.author)} \xB7 ${escapeTerminalControls2(options.blame.summary)}` : ""; const titleLine = truncateToWidth3( theme.fg("accent", theme.bold(`${escapeTerminalControls2(file.path)}${gitLabel ? ` \xB7 ${gitLabel}` : ""}`)), width, "" ); const blameLine = blameLabel ? truncateToWidth3(theme.fg("muted", blameLabel), width, "") : void 0; const renderedFooter = footer.lines.map( (line) => truncateToWidth3(theme.fg(options.error ? "error" : "muted", line), width, "") ); return fitPreviewRows( titleLine, blameLine, previewLines, renderedFooter, footer.primaryIndexes, options.availableRows ); } function renderFilePreviewHelp(theme, keybindings, width, availableRows, canEdit) { const externalEditorKey = bindingHint(keybindings, "app.editor.external", []); const editDetailed = canEdit ? externalEditorKey ? `${externalEditorKey.padEnd(6, " ")} Edit the current worktree file in the external editor` : "Edit External editor action is unbound" : "Edit Historical revisions are read-only"; const editCompact = canEdit ? externalEditorKey ? `${externalEditorKey} edit worktree` : "External editor unbound" : "Historical revision \xB7 read-only"; const detailed = [ theme.fg("accent", theme.bold("Preview actions")), "Enter Add selected lines and close File Context", "A Add selected lines and keep browsing", "Space Set or clear the range anchor; arrows extend the range", "[ / ] Select the previous or next changed hunk", "B Blame: show ownership for the current line when Git is available", "H History: browse earlier versions of this file", "R Revision: open a commit, branch, or tag", "D Git diff: review and add one explicit changed hunk", editDetailed, "Esc Return to the preview without changing selected context" ]; const compact = [ theme.fg("accent", theme.bold("Preview actions")), "Enter add \xB7 A keep browsing \xB7 Space range", "\u2191\u2193 extend \xB7 [/] hunk \xB7 B blame", "H history \xB7 R revision \xB7 D Git diff", editCompact, "Esc back without changing context" ]; const lines = availableRows < detailed.length ? compact : detailed; return fitRows2( lines.map((line) => truncateToWidth3(line, width, "")), availableRows ); } function previewFooterLines(width, startLine, endLine, tokens, selectedBytes, state, canContinue, externalEditorKey) { const nextCount = state ? state.count + 1 : void 0; const nextBytes = state ? state.totalBytes + selectedBytes : void 0; const selectedLines = endLine - startLine + 1; const snippetOverLimit = state !== void 0 && (state.maximumSnippetLines !== void 0 && selectedLines > state.maximumSnippetLines || state.maximumSnippetBytes !== void 0 && selectedBytes > state.maximumSnippetBytes); const aggregateOverLimit = state !== void 0 && nextCount !== void 0 && (nextCount > state.maximumCount || (nextBytes ?? 0) > state.maximumBytes); const warning = snippetOverLimit ? "Snippet limit exceeded" : aggregateOverLimit ? "Next prompt limit exceeded" : void 0; if (width < 74) { const selection2 = `L${startLine}-${endLine} \xB7 ~${tokens} tok`; const capacity2 = state ? warning ?? `Next ${nextCount}/${state.maximumCount}` : void 0; const lines = [ [selection2, capacity2].filter(Boolean).join(" \xB7 "), canContinue ? "Enter add \xB7 A keep browsing" : "Enter add & close", "\u2191\u2193 move \xB7 Space range \xB7 Esc back", [externalEditorKey ? `${externalEditorKey} edit` : "", "? actions"].filter(Boolean).join(" \xB7 "), "B blame \xB7 H history \xB7 R revision", "D diff \xB7 [/] hunk" ]; return { lines, primaryIndexes: [1, 3] }; } const selection = `Lines ${startLine}-${endLine} \xB7 ~${tokens} tokens`; const capacity = state ? snippetOverLimit ? "Snippet limit exceeded; shorten the range before adding" : aggregateOverLimit ? "Next prompt limit exceeded; shorten the range or review selected context" : `Next prompt: ${nextCount}/${state.maximumCount} snippets \xB7 ~${estimateTokens(nextBytes ?? 0)} tokens` : void 0; const primaryActions = canContinue ? "Enter add & close \xB7 A add & continue \xB7 \u2191\u2193 move \xB7 Space range \xB7 Esc back" : "Enter add & close \xB7 \u2191\u2193 move \xB7 Space range \xB7 Esc back"; const editActions = [externalEditorKey ? `${externalEditorKey} edit` : "", "? actions"].filter(Boolean).join(" \xB7 "); return { lines: [ [selection, capacity].filter(Boolean).join(" \xB7 "), primaryActions, editActions, "B blame \xB7 H history \xB7 R revision \xB7 D diff \xB7 [/] hunk" ], primaryIndexes: [1, 2] }; } function selectionRange(anchor, cursor) { const rangeAnchor = anchor ?? cursor; return { start: Math.min(rangeAnchor, cursor), end: Math.max(rangeAnchor, cursor) }; } function visiblePreviewOffset(current, cursor, height) { if (cursor < current) return cursor; if (cursor >= current + height) return cursor - height + 1; return current; } function estimateTokens(bytes) { return bytes === 0 ? 0 : Math.max(1, Math.ceil(bytes / 4)); } function fitPreviewRows(title, blame, previewLines, footerLines, primaryFooterIndexes, height) { let visibleTitle = title; let visibleBlame = blame; const visiblePreview = [...previewLines]; const visibleFooter = footerLines.map((line, index) => ({ line, primary: primaryFooterIndexes.includes(index) })); const rowCount = () => (visibleTitle ? 1 : 0) + (visibleBlame ? 1 : 0) + visiblePreview.length + visibleFooter.length; while (rowCount() > height) { if (visibleBlame) { visibleBlame = void 0; continue; } if (visiblePreview.length > 1) { visiblePreview.pop(); continue; } let optionalFooter = -1; for (let index = visibleFooter.length - 1; index >= 0; index -= 1) { if (!visibleFooter[index]?.primary) { optionalFooter = index; break; } } if (optionalFooter >= 0) { visibleFooter.splice(optionalFooter, 1); continue; } if (visibleTitle) { visibleTitle = void 0; continue; } if (visiblePreview.length > 0) { visiblePreview.pop(); continue; } break; } return [ ...visibleTitle ? [visibleTitle] : [], ...visibleBlame ? [visibleBlame] : [], ...visiblePreview, ...visibleFooter.map(({ line }) => line) ]; } function fitRows2(lines, height) { if (lines.length <= height) return lines; if (height <= 1) return lines.slice(0, 1); return [...lines.slice(0, height - 1), lines.at(-1) ?? ""]; } function escapeTerminalControls2(text) { return [...text].map((character) => { if (character === " ") return " "; const code = character.charCodeAt(0); if (code <= 31 || code >= 127 && code <= 159) { return `\\x${code.toString(16).padStart(2, "0")}`; } return character; }).join(""); } // src/file-search.ts var MAX_SEARCH_QUERY_LENGTH = 256; var MAX_SEARCH_QUERY_PARTS = 8; var PATH_SCORE_PENALTY = 50; var PREFIX_SCORE = 100; var SUBSTRING_SCORE = 200; var TYPO_SCORE = 300; var SUBSEQUENCE_SCORE = 400; var TOKENIZED_SCORE = 500; var NO_MATCH = Number.POSITIVE_INFINITY; var PATH_PART_SEPARATOR = /[/._\-\s]+/u; var ProjectFileSearch = class { files; entries; constructor(files) { this.files = [...files]; this.entries = this.files.map((path, originalIndex) => { const normalizedPath = safeTerminalText(path).toLowerCase(); const normalizedBasename = normalizedPath.slice(normalizedPath.lastIndexOf("/") + 1); return { path, normalizedPath, normalizedBasename, basenameParts: splitPathParts(normalizedBasename), pathParts: splitPathParts(normalizedPath), originalIndex }; }); } search(query) { const trimmedQuery = safeTerminalText(query).trim(); if (!trimmedQuery) return [...this.files]; if (trimmedQuery.length > MAX_SEARCH_QUERY_LENGTH) return []; const normalizedQuery = trimmedQuery.toLowerCase(); const queryParts = splitPathParts(normalizedQuery); const typoDistanceCache = /* @__PURE__ */ new Map(); return this.entries.flatMap((entry) => { const score = scoreEntry(normalizedQuery, queryParts, entry, typoDistanceCache); return Number.isFinite(score) ? [{ path: entry.path, score, originalIndex: entry.originalIndex }] : []; }).sort((left, right) => left.score - right.score || left.originalIndex - right.originalIndex).map((result) => result.path); } }; function scoreEntry(query, queryParts, entry, typoDistanceCache) { const basenameScore = scoreField(query, queryParts, entry.normalizedBasename, entry.basenameParts, typoDistanceCache); const pathScore = scoreField(query, queryParts, entry.normalizedPath, entry.pathParts, typoDistanceCache); return Math.min(basenameScore, pathScore + PATH_SCORE_PENALTY); } function scoreField(query, queryParts, text, textParts, typoDistanceCache) { const directScore = scoreText(query, text); if (directScore < TYPO_SCORE) return directScore; const typoScore = scoreTypo(query, textParts, typoDistanceCache); if (Number.isFinite(typoScore)) return Math.min(directScore, typoScore); if (Number.isFinite(directScore)) return directScore; return scoreTokenized(queryParts, textParts, typoDistanceCache); } function scoreTokenized(queryParts, candidateParts, typoDistanceCache) { if (queryParts.length < 2 || queryParts.length > MAX_SEARCH_QUERY_PARTS) return NO_MATCH; let totalScore = TOKENIZED_SCORE; for (const queryPart of queryParts) { let bestDirectScore = NO_MATCH; for (let index = 0; index < candidateParts.length; index += 1) { bestDirectScore = Math.min(bestDirectScore, scoreText(queryPart, candidateParts[index] ?? "") + index * 2); } const bestPartScore = bestDirectScore < TYPO_SCORE ? bestDirectScore : Math.min(bestDirectScore, scoreTypo(queryPart, candidateParts, typoDistanceCache)); if (!Number.isFinite(bestPartScore)) return NO_MATCH; totalScore += bestPartScore; } return totalScore; } function scoreText(query, text) { if (query === text) return 0; if (text.startsWith(query)) return PREFIX_SCORE + lengthPenalty(query, text); const substringIndex = text.indexOf(query); if (substringIndex >= 0) { return SUBSTRING_SCORE + substringIndex * 2 + lengthPenalty(query, text); } return scoreSubsequence(query, text); } function scoreSubsequence(query, text) { let queryIndex = 0; let firstMatch = -1; let previousMatch = -1; let gaps = 0; let boundaryMatches = 0; let consecutiveMatches = 0; for (let textIndex = 0; textIndex < text.length && queryIndex < query.length; textIndex += 1) { if (text[textIndex] !== query[queryIndex]) continue; if (firstMatch < 0) firstMatch = textIndex; if (previousMatch >= 0) { const gap = textIndex - previousMatch - 1; gaps += gap; if (gap === 0) consecutiveMatches += 1; } if (textIndex === 0 || PATH_PART_SEPARATOR.test(text[textIndex - 1] ?? "")) { boundaryMatches += 1; } previousMatch = textIndex; queryIndex += 1; } if (queryIndex !== query.length) return NO_MATCH; return SUBSEQUENCE_SCORE + gaps * 3 + firstMatch * 2 + lengthPenalty(query, text) - boundaryMatches * 8 - consecutiveMatches; } function scoreTypo(query, parts, typoDistanceCache) { const maxDistance = allowedTypoDistance(query.length); if (maxDistance === 0) return NO_MATCH; let bestScore = NO_MATCH; for (let index = 0; index < parts.length; index += 1) { const part = parts[index] ?? ""; if (Math.abs(query.length - part.length) > maxDistance) continue; const distance = cachedTypoDistance(query, part, maxDistance, typoDistanceCache); if (distance === void 0 || distance === 0) continue; bestScore = Math.min(bestScore, TYPO_SCORE + distance * 20 + index * 2 + Math.abs(query.length - part.length)); } return bestScore; } function cachedTypoDistance(query, part, maxDistance, cache) { let queryCache = cache.get(query); if (!queryCache) { queryCache = /* @__PURE__ */ new Map(); cache.set(query, queryCache); } if (queryCache.has(part)) return queryCache.get(part); const distance = boundedDamerauLevenshtein(query, part, maxDistance); queryCache.set(part, distance); return distance; } function boundedDamerauLevenshtein(left, right, maxDistance) { if (Math.abs(left.length - right.length) > maxDistance) return void 0; const unreachable = maxDistance + 1; let previousPrevious = Array.from({ length: left.length + 1 }, () => unreachable); let previous = Array.from({ length: left.length + 1 }, (_, index) => index <= maxDistance ? index : unreachable); for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) { const current = Array.from({ length: left.length + 1 }, () => unreachable); if (rightIndex <= maxDistance) current[0] = rightIndex; const start = Math.max(1, rightIndex - maxDistance); const end = Math.min(left.length, rightIndex + maxDistance); for (let leftIndex = start; leftIndex <= end; leftIndex += 1) { const substitutionCost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1; current[leftIndex] = Math.min( (previous[leftIndex] ?? unreachable) + 1, (current[leftIndex - 1] ?? unreachable) + 1, (previous[leftIndex - 1] ?? unreachable) + substitutionCost ); if (leftIndex > 1 && rightIndex > 1 && left[leftIndex - 1] === right[rightIndex - 2] && left[leftIndex - 2] === right[rightIndex - 1]) { current[leftIndex] = Math.min( current[leftIndex] ?? unreachable, (previousPrevious[leftIndex - 2] ?? unreachable) + 1 ); } } previousPrevious = previous; previous = current; } const distance = previous[left.length] ?? unreachable; return distance <= maxDistance ? distance : void 0; } function allowedTypoDistance(queryLength) { if (queryLength < 5) return 0; return queryLength < 10 ? 1 : 2; } function splitPathParts(value) { return value.split(PATH_PART_SEPARATOR).filter(Boolean); } function lengthPenalty(query, text) { return Math.max(0, text.length - query.length) / 100; } // src/file-context-explorer.ts var RESERVED_APP_ROWS = 3; var EXPLORER_CHROME_ROWS = 4; var HISTORY_CHROME_ROWS = 3; var DIFF_CHROME_ROWS = 3; var FileQuoteExplorer = class { constructor(options) { this.options = options; this.fileSearch = new ProjectFileSearch(options.files); this.fileBrowser = new ProjectFileBrowser(options.files); this.fileItems = this.fileBrowser.list(""); this.contentSearch = new ContentSearchSession({ tui: options.tui, theme: options.theme, keybindings: options.keybindings, files: options.files, cwd: options.cwd, loadFile: options.loadFile, onPreview: (match) => { void this.openFile(match.path, { returnMode: "contents", cursorIndex: match.lineNumber - 1, match }); }, onReference: (path) => this.finish({ kind: "reference", path }), onSwitchFiles: () => this.showFileSearch(), onCancel: () => this.finish(this.rootExit("back")) }); } options; search = new Input2(); contentSearch; revisionInput = new Input2(); fileSearch; fileBrowser; fileItems; currentDirectory = ""; directoryPositions = /* @__PURE__ */ new Map(); selectedFileIndex = 0; fileScrollOffset = 0; mode = "files"; previewReturnMode = "files"; activeContentMatch; loadedFile; loadedGit; previewCursor = 0; previewAnchor; previewScrollOffset = 0; hunkIndex = -1; blame; history = []; historyIndex = 0; loadedRevision; diffHunkIndex = 0; diffScrollOffset = 0; detailRequest = 0; detailController; openRequest = 0; openController; editRequest = 0; editController; loading = false; error; finished = false; disposed = false; isFocused = false; get focused() { return this.isFocused; } set focused(value) { this.isFocused = value; this.search.focused = value && this.mode === "files"; this.contentSearch.focused = value && this.mode === "contents"; this.revisionInput.focused = value && this.mode === "revision"; } render(width) { const safeWidth = Math.max(1, width); if (this.mode === "files") return this.renderFileList(safeWidth); if (this.mode === "contents") return this.renderContentSearch(safeWidth); if (this.mode === "history") return this.renderHistory(safeWidth); if (this.mode === "revision") return this.renderRevisionInput(safeWidth); if (this.mode === "diff") return this.renderDiff(safeWidth); if (this.mode === "preview-help") return this.renderPreviewHelp(safeWidth); return this.renderPreview(safeWidth); } handleInput(data) { if (this.finished || this.disposed) return; if (matchesKey2(data, Key2.ctrl("c"))) { this.finish(this.rootExit("close")); return; } if (this.mode === "files") this.handleFileInput(data); else if (this.mode === "contents") this.handleContentInput(data); else if (this.mode === "history") this.handleHistoryInput(data); else if (this.mode === "revision") this.handleRevisionInput(data); else if (this.mode === "diff") this.handleDiffInput(data); else if (this.mode === "preview-help") this.handlePreviewHelpInput(data); else this.handlePreviewInput(data); if (!this.finished) this.options.tui.requestRender(); } invalidate() { this.search.invalidate(); this.contentSearch.invalidate(); this.revisionInput.invalidate(); } dispose() { if (this.disposed) return; this.disposed = true; this.contentSearch.dispose(); this.cancelOpenRequest(); this.cancelDetailRequest(); this.cancelEditRequest(); if (!this.finished) { this.finished = true; this.options.done(void 0); } } renderFileList(width) { const availableRows = Math.max(1, this.options.tui.terminal.rows - RESERVED_APP_ROWS); const listHeight = Math.max(1, availableRows - EXPLORER_CHROME_ROWS); this.keepFileVisible(listHeight); const project = this.options.gitContext?.project; const repositoryLabel = project ? ` \xB7 ${safeTerminalText(project.branch)}@${project.head.slice(0, 12)}${project.dirty ? " \xB7 dirty" : ""}` : ""; const queryLabel = this.options.theme.fg("muted", "Search: "); const queryWidth = Math.max(1, width - visibleWidth3(queryLabel)); const searchLine = `${queryLabel}${this.search.render(queryWidth)[0] ?? ""}`; return fitRows3( renderFileBrowser({ theme: this.options.theme, keybindings: this.options.keybindings, width, height: listHeight, items: this.fileItems, selectedIndex: this.selectedFileIndex, scrollOffset: this.fileScrollOffset, currentDirectory: this.currentDirectory, searchLine, searchActive: this.isFileSearchActive(), repositoryLabel, statuses: this.options.gitContext?.statuses, loading: this.loading, error: this.error }), availableRows ); } renderContentSearch(width) { const availableRows = Math.max(1, this.options.tui.terminal.rows - RESERVED_APP_ROWS); return this.contentSearch.render(width, availableRows, this.loading, this.error); } renderPreview(width) { const availableRows = Math.max(1, this.options.tui.terminal.rows - RESERVED_APP_ROWS); if (!this.loadedFile) return [this.options.theme.fg("warning", "Loading preview\u2026")]; return renderFilePreview({ theme: this.options.theme, keybindings: this.options.keybindings, width, availableRows, file: this.loadedFile, fileGit: this.loadedGit, project: this.options.gitContext?.project, revision: this.loadedRevision, contentMatch: this.activeContentMatch, blame: this.blame, cursor: this.previewCursor, anchor: this.previewAnchor, scrollOffset: this.previewScrollOffset, error: this.error, selectedContext: this.options.getSelectedContextState?.(), canContinue: this.options.onAddAndContinue !== void 0, canEdit: this.options.editFile !== void 0 && this.loadedRevision === void 0 }); } renderPreviewHelp(width) { const availableRows = Math.max(1, this.options.tui.terminal.rows - RESERVED_APP_ROWS); return renderFilePreviewHelp( this.options.theme, this.options.keybindings, width, availableRows, this.options.editFile !== void 0 && this.loadedRevision === void 0 ); } renderRevisionInput(width) { const availableRows = Math.max(1, this.options.tui.terminal.rows - RESERVED_APP_ROWS); const title = this.options.theme.fg( "accent", this.options.theme.bold(`Open Git revision \xB7 ${safeTerminalText(this.loadedFile?.path ?? "")}`) ); const label = this.options.theme.fg("muted", "Revision: "); const inputWidth = Math.max(1, width - visibleWidth3(label)); const input = `${label}${this.revisionInput.render(inputWidth)[0] ?? ""}`; const state = this.error ? this.options.theme.fg("error", safeTerminalText(this.error)) : this.loading ? this.options.theme.fg("warning", "Loading revision\u2026") : this.options.theme.fg("muted", "Enter open commit/branch/tag \xB7 Esc preview"); return fitRows3( [truncateToWidth4(title, width, ""), truncateToWidth4(input, width, ""), truncateToWidth4(state, width, "")], availableRows ); } renderDiff(width) { const availableRows = Math.max(1, this.options.tui.terminal.rows - RESERVED_APP_ROWS); const contentHeight = Math.max(1, availableRows - DIFF_CHROME_ROWS); const hunk = this.loadedGit?.hunks[this.diffHunkIndex]; const path = safeTerminalText(this.loadedFile?.path ?? ""); const title = this.options.theme.fg("accent", this.options.theme.bold(`Git diff \xB7 ${path} \xB7 HEAD \u2192 worktree`)); const hunkLines = hunk?.lines ?? ["No changed hunks"]; const maxScroll = Math.max(0, hunkLines.length - contentHeight); this.diffScrollOffset = Math.min(this.diffScrollOffset, maxScroll); const lines = hunkLines.slice(this.diffScrollOffset, this.diffScrollOffset + contentHeight).map( (line) => truncateToWidth4( line.startsWith("+") ? this.options.theme.fg("success", safeTerminalText(line)) : line.startsWith("-") ? this.options.theme.fg("error", safeTerminalText(line)) : safeTerminalText(line), width, "" ) ); const text = hunk?.lines.join("\n") ?? ""; const tokens = Math.max(1, Math.ceil(Buffer.byteLength(text, "utf8") / 4)); const footer = this.error ? this.options.theme.fg("error", safeTerminalText(this.error)) : `~${tokens} tokens \xB7 Enter attach diff \xB7 Hunk ${hunk ? this.diffHunkIndex + 1 : 0}/${this.loadedGit?.hunks.length ?? 0} \xB7 rows ${hunkLines.length === 0 ? 0 : this.diffScrollOffset + 1}-${Math.min(hunkLines.length, this.diffScrollOffset + contentHeight)}/${hunkLines.length} \xB7 \u2191\u2193 scroll \xB7 [] navigate \xB7 Esc preview`; return fitRows3( [truncateToWidth4(title, width, ""), ...lines, truncateToWidth4(this.options.theme.fg("muted", footer), width, "")], availableRows ); } renderHistory(width) { const availableRows = Math.max(1, this.options.tui.terminal.rows - RESERVED_APP_ROWS); const listHeight = Math.max(1, availableRows - HISTORY_CHROME_ROWS); const loadedFile = this.loadedFile; const title = this.options.theme.fg( "accent", this.options.theme.bold(`File history \xB7 ${safeTerminalText(loadedFile?.path ?? "")}`) ); const start = Math.max(0, this.historyIndex - listHeight + 1); const entries = this.history.slice(start, start + listHeight).map((entry, visibleIndex) => { const index = start + visibleIndex; const prefix = index === this.historyIndex ? "> " : " "; const date = formatHistoryDate(entry.authorTime); const line = `${prefix}${entry.commit.slice(0, 12)} \xB7 ${date} \xB7 ${safeTerminalText(entry.author)} \xB7 ${safeTerminalText(entry.summary)}`; return truncateToWidth4( index === this.historyIndex ? this.options.theme.bg("selectedBg", this.options.theme.fg("text", line)) : line, width, "" ); }); if (entries.length === 0) { entries.push(truncateToWidth4(this.options.theme.fg("muted", " No file history"), width, "")); } const footer = this.error ? safeTerminalText(this.error) : "\u2191\u2193 navigate \xB7 Enter open revision \xB7 Esc preview"; return fitRows3( [ truncateToWidth4(title, width, ""), ...entries, truncateToWidth4(this.options.theme.fg("muted", footer), width, "") ], availableRows ); } handleFileInput(data) { if (this.options.keybindings.matches(data, "tui.select.cancel")) { this.cancelFileList(); return; } if (matchesKey2(data, Key2.ctrl("f"))) { this.showContentSearch(); return; } if (matchesKey2(data, Key2.escape)) { this.cancelFileList(); return; } if (this.loading) return; if (this.options.keybindings.matches(data, "tui.select.up")) { this.selectedFileIndex = Math.max(0, this.selectedFileIndex - 1); return; } if (this.options.keybindings.matches(data, "tui.select.down")) { this.selectedFileIndex = Math.min(Math.max(0, this.fileItems.length - 1), this.selectedFileIndex + 1); return; } if (this.options.keybindings.matches(data, "tui.select.pageUp")) { this.selectedFileIndex = Math.max(0, this.selectedFileIndex - 10); return; } if (this.options.keybindings.matches(data, "tui.select.pageDown")) { this.selectedFileIndex = Math.min(Math.max(0, this.fileItems.length - 1), this.selectedFileIndex + 10); return; } if (this.options.keybindings.matches(data, "tui.select.confirm")) { this.openSelectedFileItem(); return; } if (this.options.keybindings.matches(data, "tui.input.tab")) { const item = this.fileItems[this.selectedFileIndex]; if (item?.kind === "file") this.finish({ kind: "reference", path: item.path }); return; } if (!this.isFileSearchActive() && this.currentDirectory && (matchesKey2(data, Key2.left) || matchesKey2(data, Key2.backspace))) { this.showParentDirectory(); return; } if (!this.isFileSearchActive() && matchesKey2(data, Key2.right)) { const item = this.fileItems[this.selectedFileIndex]; if (item?.kind === "directory") this.showDirectory(item.path); return; } const previousQuery = this.search.getValue(); this.search.handleInput(data); const query = this.search.getValue(); if (query !== previousQuery) { this.refreshFileItems(query); this.selectedFileIndex = 0; this.fileScrollOffset = 0; this.error = void 0; } } handleContentInput(data) { this.cancelOpenRequest(); this.error = void 0; this.contentSearch.handleInput(data); } handlePreviewInput(data) { const loadedFile = this.loadedFile; if (!loadedFile) return; const lines = loadedFile.lines; if (this.options.keybindings.matches(data, "app.editor.external")) { if (this.loadedRevision) { this.error = "Historical revisions are read-only; return to the worktree preview to edit"; } else if (this.options.editFile && !this.loading) { void this.editCurrentFile(); } else if (!this.options.editFile) { this.error = "External editing is unavailable"; } return; } if (matchesKey2(data, Key2.escape)) { this.returnToOrigin(); return; } if (data === "?") { this.cancelDetailRequest(); this.mode = "preview-help"; this.search.focused = false; this.contentSearch.focused = false; return; } if (data === "a" && this.options.onAddAndContinue) { try { const quote = this.createSelectedQuote(); this.options.onAddAndContinue(quote); this.returnToOrigin(); } catch (error) { this.error = formatError2(error); } return; } if (data === " ") { this.previewAnchor = this.previewAnchor === void 0 ? this.previewCursor : void 0; this.error = void 0; return; } if (data === "b") { void this.loadBlame(); return; } if (data === "h") { void this.loadHistory(); return; } if (data === "r") { this.mode = "revision"; this.revisionInput.setValue(""); this.revisionInput.focused = this.isFocused; this.error = void 0; return; } if (data === "d") { if (this.loadedRevision) { this.error = "Diff context is available from the worktree preview"; return; } if ((this.loadedGit?.hunks.length ?? 0) === 0) { this.error = "No changed hunks for this file"; return; } this.diffHunkIndex = Math.max(0, this.hunkIndex); this.diffScrollOffset = 0; this.mode = "diff"; this.error = void 0; return; } if (this.options.keybindings.matches(data, "tui.select.up")) { this.movePreviewCursor(Math.max(0, this.previewCursor - 1)); return; } if (this.options.keybindings.matches(data, "tui.select.down")) { this.movePreviewCursor(Math.min(Math.max(0, lines.length - 1), this.previewCursor + 1)); return; } if (this.options.keybindings.matches(data, "tui.select.pageUp")) { this.movePreviewCursor(Math.max(0, this.previewCursor - 10)); return; } if (this.options.keybindings.matches(data, "tui.select.pageDown")) { this.movePreviewCursor(Math.min(Math.max(0, lines.length - 1), this.previewCursor + 10)); return; } if (data === "]" || data === "[") { this.navigateHunk(data === "]" ? 1 : -1); return; } if (this.options.keybindings.matches(data, "tui.select.confirm")) { try { const quote = this.createSelectedQuote(); this.options.validateQuote?.(quote); this.finish({ kind: "quote", quote }); } catch (error) { this.error = formatError2(error); } } } handlePreviewHelpInput(data) { if (matchesKey2(data, Key2.escape)) this.mode = "preview"; } handleHistoryInput(data) { if (matchesKey2(data, Key2.escape)) { this.cancelDetailRequest(); this.mode = "preview"; this.error = void 0; return; } if (this.options.keybindings.matches(data, "tui.select.up")) { this.historyIndex = Math.max(0, this.historyIndex - 1); return; } if (this.options.keybindings.matches(data, "tui.select.down")) { this.historyIndex = Math.min(Math.max(0, this.history.length - 1), this.historyIndex + 1); return; } if (this.options.keybindings.matches(data, "tui.select.confirm")) { const entry = this.history[this.historyIndex]; if (entry) void this.loadRevision(entry.commit, entry.path); } } handleRevisionInput(data) { if (matchesKey2(data, Key2.escape)) { this.cancelDetailRequest(); this.mode = "preview"; this.revisionInput.focused = false; this.error = void 0; return; } if (this.loading) return; if (this.options.keybindings.matches(data, "tui.select.confirm")) { void this.loadRevision(this.revisionInput.getValue()); return; } this.revisionInput.handleInput(data); } handleDiffInput(data) { if (matchesKey2(data, Key2.escape)) { this.mode = "preview"; this.error = void 0; return; } const hunks = this.loadedGit?.hunks ?? []; const hunkLines = hunks[this.diffHunkIndex]?.lines ?? []; if (this.options.keybindings.matches(data, "tui.select.up")) { this.diffScrollOffset = Math.max(0, this.diffScrollOffset - 1); return; } if (this.options.keybindings.matches(data, "tui.select.down")) { this.diffScrollOffset = Math.min(Math.max(0, hunkLines.length - 1), this.diffScrollOffset + 1); return; } if (this.options.keybindings.matches(data, "tui.select.pageUp")) { this.diffScrollOffset = Math.max(0, this.diffScrollOffset - 10); return; } if (this.options.keybindings.matches(data, "tui.select.pageDown")) { this.diffScrollOffset = Math.min(Math.max(0, hunkLines.length - 1), this.diffScrollOffset + 10); return; } if (data === "]" || data === "[") { if (hunks.length > 0) { const direction = data === "]" ? 1 : -1; this.diffHunkIndex = (this.diffHunkIndex + direction + hunks.length) % hunks.length; this.diffScrollOffset = 0; } return; } if (this.options.keybindings.matches(data, "tui.select.confirm")) { const hunk = hunks[this.diffHunkIndex]; const loadedFile = this.loadedFile; const project = this.options.gitContext?.project; if (!hunk || !loadedFile || !project) return; try { const startLine = Math.max(1, hunk.newStart); const endLine = Math.max(startLine, hunk.newStart + hunk.newCount - 1); const quote = createFileQuoteSnapshot(loadedFile.path, startLine, endLine, hunk.lines.join("\n"), { head: project.head, branch: project.branch, status: this.loadedGit?.status?.label ?? "modified", blob: this.loadedGit?.blob, source: "git_diff", base: "HEAD" }); this.options.validateQuote?.(quote); this.finish({ kind: "quote", quote }); } catch (error) { this.error = formatError2(error); } } } async editCurrentFile() { const path = this.loadedFile?.path; const editFile = this.options.editFile; if (!path || !editFile || this.loadedRevision) return; this.cancelOpenRequest(); this.cancelDetailRequest(); this.cancelEditRequest(); const request = this.editRequest; const controller = new AbortController(); this.editController = controller; this.loading = true; this.error = void 0; this.options.tui.requestRender(); try { await editFile(path, controller.signal); if (!this.isCurrentEditRequest(request, controller, path)) return; const gitContext = this.options.gitContext; const [loadedFile, loadedGit] = await Promise.all([ this.options.loadFile(path, controller.signal), gitContext ? gitContext.refreshFileContext?.(path, controller.signal) ?? gitContext.getFileContext(path, controller.signal) : void 0 ]); if (!this.isCurrentEditRequest(request, controller, path)) return; const maximumIndex = Math.max(0, loadedFile.lines.length - 1); this.loadedFile = loadedFile; this.loadedGit = loadedGit; this.previewCursor = Math.min(this.previewCursor, maximumIndex); if (this.previewAnchor !== void 0) { this.previewAnchor = Math.min(this.previewAnchor, maximumIndex); } this.previewScrollOffset = Math.min(this.previewScrollOffset, maximumIndex); this.activeContentMatch = void 0; this.hunkIndex = -1; this.blame = void 0; this.history = []; this.error = void 0; } catch (error) { if (this.isCurrentEditRequest(request, controller, path) && !isAbortError3(error)) { this.error = formatError2(error); } } finally { if (request === this.editRequest && this.editController === controller) { this.loading = false; this.editController = void 0; } if (!this.finished && !this.disposed) this.options.tui.requestRender(true); } } async loadRevision(revision, historicalPath) { const path = this.loadedFile?.path; const gitContext = this.options.gitContext; if (!path || !gitContext) { this.error = "Git revision browsing is unavailable"; return; } const { request, signal } = this.beginDetailRequest(); this.error = void 0; this.options.tui.requestRender(); try { const loadedRevision = await gitContext.loadRevision(path, revision, historicalPath, signal); if (this.finished || request !== this.detailRequest) return; this.loadedRevision = loadedRevision; this.loadedFile = { path: loadedRevision.path, lines: loadedRevision.lines }; this.activeContentMatch = void 0; this.previewCursor = 0; this.previewAnchor = void 0; this.previewScrollOffset = 0; this.blame = void 0; this.mode = "preview"; this.revisionInput.focused = false; } catch (error) { if (request === this.detailRequest && !isAbortError3(error)) this.error = formatError2(error); } finally { this.finishDetailRequest(request); } } async loadBlame() { const path = this.loadedFile?.path; const gitContext = this.options.gitContext; if (!path || !gitContext) { this.error = "Git blame is unavailable"; return; } const { request, signal } = this.beginDetailRequest(); const requestedLine = this.previewCursor + 1; this.error = void 0; this.options.tui.requestRender(); try { const blame = await gitContext.getBlame(path, requestedLine, this.loadedRevision?.commit, signal); if (this.finished || request !== this.detailRequest || this.mode !== "preview" || requestedLine !== this.previewCursor + 1) { return; } this.blame = blame; if (!blame) this.error = "No blame information for this line"; } catch (error) { if (request === this.detailRequest && !isAbortError3(error)) this.error = formatError2(error); } finally { this.finishDetailRequest(request); } } async loadHistory() { const path = this.loadedFile?.path; const gitContext = this.options.gitContext; if (!path || !gitContext) { this.error = "Git history is unavailable"; return; } const { request, signal } = this.beginDetailRequest(); this.error = void 0; this.options.tui.requestRender(); try { const history = await gitContext.getHistory(path, signal); if (this.finished || request !== this.detailRequest || this.mode !== "preview") return; this.history = history; this.historyIndex = 0; this.mode = "history"; } catch (error) { if (request === this.detailRequest && !isAbortError3(error)) this.error = formatError2(error); } finally { this.finishDetailRequest(request); } } async openFile(path, options = {}) { this.cancelOpenRequest(); const request = this.openRequest; const controller = new AbortController(); this.openController = controller; this.loading = true; this.error = void 0; this.options.tui.requestRender(); try { const [loadedFile, loadedGit] = await Promise.all([ this.options.loadFile(path, controller.signal), this.options.gitContext?.getFileContext(path, controller.signal) ]); if (!this.isCurrentOpenRequest(request, controller)) return; this.loadedFile = loadedFile; this.loadedGit = loadedGit; this.loadedRevision = void 0; this.mode = "preview"; this.previewReturnMode = options.returnMode ?? "files"; this.activeContentMatch = options.match; this.previewCursor = Math.max(0, Math.min(Math.max(0, loadedFile.lines.length - 1), options.cursorIndex ?? 0)); this.previewAnchor = void 0; this.previewScrollOffset = Math.max(0, this.previewCursor - 1); this.hunkIndex = -1; this.blame = void 0; this.history = []; this.detailRequest += 1; this.error = void 0; this.search.focused = false; this.contentSearch.focused = false; } catch (error) { if (this.isCurrentOpenRequest(request, controller) && !isAbortError3(error)) { this.error = formatError2(error); } } finally { if (request === this.openRequest) { this.loading = false; this.openController = void 0; } if (!this.finished && !this.disposed) this.options.tui.requestRender(); } } navigateHunk(direction) { const hunks = this.loadedGit?.hunks ?? []; const lineCount = this.loadedFile?.lines.length ?? 0; if (hunks.length === 0 || lineCount === 0) { this.error = "No changed hunks for this file"; return; } this.hunkIndex = this.hunkIndex < 0 ? direction > 0 ? 0 : hunks.length - 1 : (this.hunkIndex + direction + hunks.length) % hunks.length; const hunk = hunks[this.hunkIndex]; const selectedLines = hunk.changedLines.length > 0 ? hunk.changedLines : [hunk.newStart]; const start = Math.max(0, Math.min(...selectedLines) - 1); const end = Math.max(start, Math.min(lineCount - 1, Math.max(...selectedLines) - 1)); this.previewAnchor = start; this.movePreviewCursor(end); this.error = void 0; } showContentSearch() { this.cancelOpenRequest(); this.mode = "contents"; this.search.focused = false; this.contentSearch.activate(); this.contentSearch.focused = this.isFocused; this.error = void 0; } showFileSearch() { this.contentSearch.deactivate(); this.cancelOpenRequest(); this.mode = "files"; this.search.focused = this.isFocused; this.error = void 0; } openSelectedFileItem() { const item = this.fileItems[this.selectedFileIndex]; if (item?.kind === "directory") { this.showDirectory(item.path); return; } if (item?.kind === "file") void this.openFile(item.path); } showDirectory(directory) { this.directoryPositions.set(this.currentDirectory, { index: this.selectedFileIndex, offset: this.fileScrollOffset }); this.currentDirectory = directory; this.refreshFileItems(""); const position = this.directoryPositions.get(directory); this.selectedFileIndex = Math.min(position?.index ?? 0, Math.max(0, this.fileItems.length - 1)); this.fileScrollOffset = Math.min(position?.offset ?? 0, this.selectedFileIndex); this.error = void 0; } showParentDirectory() { this.cancelOpenRequest(); this.currentDirectory = parentProjectDirectory(this.currentDirectory); this.refreshFileItems(""); const position = this.directoryPositions.get(this.currentDirectory); this.selectedFileIndex = Math.min(position?.index ?? 0, Math.max(0, this.fileItems.length - 1)); this.fileScrollOffset = Math.min(position?.offset ?? 0, this.selectedFileIndex); this.error = void 0; } refreshFileItems(query) { this.fileItems = this.isFileSearchActive(query) ? this.fileBrowser.searchResults(this.fileSearch.search(query)) : this.fileBrowser.list(this.currentDirectory); } isFileSearchActive(query = this.search.getValue()) { return safeTerminalText(query).trim().length > 0; } cancelFileList() { if (this.currentDirectory && !this.isFileSearchActive()) { this.showParentDirectory(); return; } this.finish(this.rootExit("back")); } cancelOpenRequest() { this.openRequest += 1; this.openController?.abort(); this.openController = void 0; this.loading = false; } isCurrentOpenRequest(request, controller) { return !this.finished && !this.disposed && request === this.openRequest && this.openController === controller && !controller.signal.aborted; } isCurrentEditRequest(request, controller, path) { return !this.finished && !this.disposed && this.mode === "preview" && this.loadedRevision === void 0 && this.loadedFile?.path === path && request === this.editRequest && this.editController === controller && !controller.signal.aborted; } beginDetailRequest() { this.cancelDetailRequest(); const request = this.detailRequest; const controller = new AbortController(); this.detailController = controller; this.loading = true; return { request, signal: controller.signal }; } finishDetailRequest(request) { if (request === this.detailRequest) { this.loading = false; this.detailController = void 0; } if (!this.finished && !this.disposed) this.options.tui.requestRender(); } cancelDetailRequest() { this.detailRequest += 1; this.detailController?.abort(); this.detailController = void 0; this.loading = false; } cancelEditRequest() { this.editRequest += 1; this.editController?.abort(); this.editController = void 0; this.loading = false; } movePreviewCursor(next) { if (next === this.previewCursor) return; this.cancelDetailRequest(); this.previewCursor = next; this.blame = void 0; this.error = void 0; } createSelectedQuote() { const loadedFile = this.loadedFile; if (!loadedFile) throw new Error("No file is open"); const anchor = this.previewAnchor ?? this.previewCursor; const project = this.options.gitContext?.project; const revision = this.loadedRevision; return createFileQuote( loadedFile.path, loadedFile.lines, anchor, this.previewCursor, project ? { head: project.head, branch: project.branch, status: revision ? "historical" : this.loadedGit?.status?.label ?? "clean", revision: revision?.revision, blob: revision?.blob ?? this.loadedGit?.blob, source: revision ? "revision" : "worktree", base: revision ? void 0 : "HEAD" } : void 0 ); } returnToOrigin() { this.cancelDetailRequest(); this.cancelOpenRequest(); this.cancelEditRequest(); this.mode = this.previewReturnMode; this.loadedFile = void 0; this.loadedGit = void 0; this.loadedRevision = void 0; this.previewAnchor = void 0; this.blame = void 0; this.error = void 0; this.search.focused = this.isFocused && this.mode === "files"; this.contentSearch.focused = this.isFocused && this.mode === "contents"; } finish(result) { this.finished = true; this.contentSearch.dispose(); this.cancelOpenRequest(); this.cancelDetailRequest(); this.cancelEditRequest(); this.options.done(result); } rootExit(kind) { return this.options.rootNavigation ? { kind } : void 0; } keepFileVisible(height) { if (this.selectedFileIndex < this.fileScrollOffset) this.fileScrollOffset = this.selectedFileIndex; if (this.selectedFileIndex >= this.fileScrollOffset + height) { this.fileScrollOffset = this.selectedFileIndex - height + 1; } } }; function fitRows3(lines, height) { if (lines.length <= height) return lines; if (height <= 1) return lines.slice(0, 1); return [...lines.slice(0, height - 1), lines.at(-1) ?? ""]; } function formatHistoryDate(authorTime) { const date = new Date(authorTime * 1e3); return Number.isFinite(date.getTime()) ? date.toISOString().slice(0, 10) : "unknown-date"; } function isAbortError3(error) { return error instanceof Error && error.name === "AbortError"; } function formatError2(error) { return error instanceof Error ? error.message : String(error); } // src/file-context-settings.ts import { randomUUID } from "node:crypto"; import { constants } from "node:fs"; import { mkdir, open, rename, rm, writeFile } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; import { getAgentDir as getAgentDir2 } from "@earendil-works/pi-coding-agent"; var FILE_CONTEXT_SETTINGS_FILE = "pi-file-context.json"; var MAX_SETTINGS_BYTES = 64 * 1024; var DEFAULT_FILE_CONTEXT_SETTINGS = { openShortcut: "ctrl+shift+x" }; var MODIFIERS = /* @__PURE__ */ new Set(["ctrl", "shift", "alt", "super"]); var BASE_KEYS = /* @__PURE__ */ new Set([ ..."abcdefghijklmnopqrstuvwxyz0123456789", "`", "-", "=", "[", "]", "\\", ";", "'", ",", ".", "/", "!", "@", "#", "$", "%", "^", "&", "*", "(", ")", "_", "+", "|", "~", "{", "}", ":", "<", ">", "?", "escape", "esc", "enter", "return", "tab", "space", "backspace", "delete", "insert", "clear", "home", "end", "pageup", "pagedown", "up", "down", "left", "right", ...Array.from({ length: 12 }, (_unused, index) => `f${index + 1}`) ]); var mutationQueues = /* @__PURE__ */ new Map(); function fileContextSettingsPath() { return join(getAgentDir2(), FILE_CONTEXT_SETTINGS_FILE); } async function loadFileContextSettings(settingsPath = fileContextSettingsPath()) { await awaitFileContextSettingsWrites(settingsPath); const snapshot = await readSettingsSnapshot(settingsPath); if (snapshot.kind === "loaded") return { settings: snapshot.settings }; if (snapshot.kind === "missing") { return { settings: { ...DEFAULT_FILE_CONTEXT_SETTINGS } }; } return { settings: { ...DEFAULT_FILE_CONTEXT_SETTINGS }, warning: snapshot.warning, invalidReason: snapshot.reason }; } function updateFileContextSettings(openShortcut, options = {}) { const settingsPath = options.settingsPath ?? fileContextSettingsPath(); return enqueueMutation(settingsPath, async () => { options.signal?.throwIfAborted(); const snapshot = await readSettingsSnapshot(settingsPath); if (snapshot.kind === "invalid") { throw new Error(`File Context settings are invalid: ${snapshot.reason}`); } const updated = { ...snapshot.kind === "loaded" ? snapshot.document : {}, openShortcut }; const settings = normalizeFileContextSettings(updated); if (!settings) throw new Error("File Context settings update is invalid"); await publishSettings(settingsPath, updated, options); return settings; }); } async function awaitFileContextSettingsWrites(settingsPath = fileContextSettingsPath()) { await mutationQueues.get(settingsPath); } function normalizeKeyId(value) { if (typeof value !== "string") return void 0; const normalized = value.trim().toLowerCase(); const base = [...BASE_KEYS].sort((left, right) => right.length - left.length).find((candidate) => normalized === candidate || normalized.endsWith(`+${candidate}`)); if (!base) return void 0; const prefix = normalized.slice(0, normalized.length - base.length); if (!prefix) return base; if (/^f(?:[1-9]|1[0-2])$/.test(base) || !prefix.endsWith("+")) return void 0; const modifiers = prefix.slice(0, -1).split("+"); if (modifiers.length === 0 || modifiers.some((modifier) => !MODIFIERS.has(modifier)) || new Set(modifiers).size !== modifiers.length) { return void 0; } return normalized; } function normalizeFileContextSettings(value) { if (!isSettingsDocument(value)) return void 0; if (!Object.hasOwn(value, "openShortcut")) { return { ...DEFAULT_FILE_CONTEXT_SETTINGS }; } const openShortcut = Reflect.get(value, "openShortcut"); if (openShortcut === null) return { openShortcut: null }; const normalized = normalizeKeyId(openShortcut); return normalized ? { openShortcut: normalized } : void 0; } function enqueueMutation(settingsPath, mutation) { const previous = mutationQueues.get(settingsPath) ?? Promise.resolve(); const result = previous.then(mutation, mutation); const settled = result.then( () => void 0, () => void 0 ); mutationQueues.set(settingsPath, settled); void settled.finally(() => { if (mutationQueues.get(settingsPath) === settled) mutationQueues.delete(settingsPath); }); return result; } async function readSettingsSnapshot(settingsPath) { let source; try { source = await readSettingsContents(settingsPath); } catch (error) { if (isNodeError(error) && error.code === "ENOENT") return { kind: "missing" }; const reason = safeReadError(error); return { kind: "invalid", reason, warning: `Cannot read File Context settings: ${reason}` }; } let document; try { document = JSON.parse(source); } catch (error) { return { kind: "invalid", reason: "invalid JSON", warning: `Cannot parse File Context settings: ${formatError3(error)}` }; } if (!isSettingsDocument(document)) { return { kind: "invalid", reason: "settings must contain a JSON object", warning: "File Context settings must contain a JSON object." }; } const settings = normalizeFileContextSettings(document); if (!settings) { return { kind: "invalid", reason: 'setting "openShortcut" must be a valid Pi key string or null', warning: 'File Context setting "openShortcut" must be a valid Pi key string or null.' }; } return { kind: "loaded", document, settings }; } async function readSettingsContents(settingsPath) { const flags = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | (constants.O_NOFOLLOW ?? 0); const handle = await open(settingsPath, flags); try { const stats = await handle.stat(); if (!stats.isFile()) throw new Error("settings path is not a regular file"); if (stats.size > MAX_SETTINGS_BYTES) { throw new Error(`settings file exceeds ${MAX_SETTINGS_BYTES} bytes`); } const buffer = Buffer.alloc(MAX_SETTINGS_BYTES + 1); let offset = 0; while (offset < buffer.byteLength) { const { bytesRead } = await handle.read(buffer, offset, buffer.byteLength - offset, offset); if (bytesRead === 0) break; offset += bytesRead; } if (offset > MAX_SETTINGS_BYTES) { throw new Error(`settings file exceeds ${MAX_SETTINGS_BYTES} bytes`); } try { return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset)); } catch { throw new Error("settings file is not valid UTF-8"); } } finally { await handle.close(); } } async function publishSettings(settingsPath, document, options) { options.signal?.throwIfAborted(); const contents = `${JSON.stringify(document, null, 2)} `; if (Buffer.byteLength(contents, "utf8") > MAX_SETTINGS_BYTES) { throw new Error(`settings document exceeds ${MAX_SETTINGS_BYTES} bytes`); } const directory = dirname(settingsPath); await mkdir(directory, { recursive: true }); options.signal?.throwIfAborted(); const temporaryPath = join(directory, `.${basename(settingsPath)}.${process.pid}.${randomUUID()}.tmp`); try { await writeFile(temporaryPath, contents, { encoding: "utf8", flag: "wx", mode: 384, signal: options.signal }); await options.beforeRename?.(temporaryPath, settingsPath); options.signal?.throwIfAborted(); await rename(temporaryPath, settingsPath); } finally { await rm(temporaryPath, { force: true }).catch(() => void 0); } } function isSettingsDocument(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } function isNodeError(error) { return error instanceof Error && "code" in error; } function safeReadError(error) { if (isNodeError(error) && error.code === "ELOOP") return "settings path is not a regular file"; return formatError3(error); } function formatError3(error) { return error instanceof Error ? error.message : String(error); } // src/file-context-menu.ts async function showFileContextMenu(ctx, options) { const { defineMenu, runMenu } = await import("@narumitw/pi-tui-kit"); if (!options.isCurrent() || options.signal.aborted) return { kind: "stale" }; let addRequested = false; let selectedQuoteId; const menu = defineMenu({ start: options.start === "remove" ? "selected" : "main", screens: { main: ({ state }) => ({ kind: "actions", title: "File Context", lines: [ `Next prompt context: ${state.quotes.length}/${state.maximumQuotes} snippets \xB7 ~${estimateTokens2(state.totalBytes)} tokens`, `Shortcut: ${formatShortcut(state.shortcut)}` ], items: [ { id: "add", label: "Add context snippet", description: "Browse project files and select lines", action: "add", busyLabel: "Scanning project files", disabled: addDisabledReason(state) !== void 0, disabledReason: addDisabledReason(state) }, { id: "selected", label: `Review selected context (${state.quotes.length})`, description: "Preview exact snapshots before removing them", to: "selected", disabled: state.quotes.length === 0, disabledReason: state.quotes.length === 0 ? "No context selected for the next prompt" : void 0 }, { id: "settings", label: "Settings", description: "Configure the shortcut used to open File Context", to: "settings", disabled: state.quotes.length > 0, disabledReason: state.quotes.length > 0 ? "Submit or remove selected context first; applying a shortcut reloads Pi" : void 0 }, { id: "status", label: "Status", description: "Review selected context, limits, and active settings", to: "status" }, { id: "help", label: "Help", description: "Review shortcuts and attachment behavior", to: "help" } ], hint: "close" }), selected: ({ state }) => state.quotes.length === 0 ? { kind: "detail", title: "Selected context", lines: ["No context selected for the next prompt."], hint: "back" } : { kind: "choice", title: "Selected context", lines: [`${state.quotes.length} snippets \xB7 Enter reviews the exact snapshot before removal`], items: state.quotes.map((quote, index) => ({ id: quote.id, label: `${index + 1}. ${quote.path}`, description: `lines ${quote.startLine}-${quote.endLine} \xB7 ~${estimateTokens2(Buffer.byteLength(quote.text, "utf8"))} tokens`, details: [`Lines ${quote.startLine}-${quote.endLine} \xB7 Preview: ${singleLinePreview(quote.text)}`] })), action: "review", viewportSize: 8, hint: "back" }, quote: ({ state }) => { const quote = state.quotes.find((candidate) => candidate.id === selectedQuoteId); if (!quote) { return { kind: "detail", title: "Review context snippet", lines: ["That snippet is no longer selected. Go back to refresh the list."], hint: "back" }; } return { kind: "review", title: "Review context snippet", lines: [ quote.path, `Lines ${quote.startLine}-${quote.endLine} \xB7 ~${estimateTokens2(Buffer.byteLength(quote.text, "utf8"))} tokens` ], content: quote.text, format: { kind: "text" }, viewportSize: "adaptive", confirm: { id: "remove", label: "Remove from next prompt", action: "remove" }, hint: "back" }; }, settings: ({ state }) => state.settingsInvalidReason ? { kind: "detail", title: "File Context Settings \xB7 Read only", lines: [ `Invalid settings file. Fix ${safeTerminalText2(state.settingsPath)} before saving.`, safeTerminalText2(state.settingsInvalidReason) ], hint: "back" } : { kind: "settings", title: "File Context Settings", lines: [ `User settings \xB7 ${safeTerminalText2(state.settingsPath)}`, "Saving reloads Pi so the new shortcut becomes active." ], items: [ { id: "openShortcut", label: "Open shortcut", description: "Set the global shortcut used to open the file browser.", currentValue: state.shortcut ?? "none", action: "open-shortcut" } ], hint: "back" }, shortcut: ({ state }) => ({ kind: "input", title: "File Context shortcut", lines: [ `Configured: ${state.shortcut ?? "none"}`, "Use a Pi key identifier such as ctrl+shift+x or f8.", "Submit an empty value to disable the shortcut." ], placeholder: state.shortcut ?? "", action: "set-shortcut", hint: "back" }), status: ({ state }) => ({ kind: "detail", title: "File Context Status", lines: [ `Next prompt context: ${state.quotes.length}/${state.maximumQuotes} snippets`, `Estimated context: ~${estimateTokens2(state.totalBytes)} tokens \xB7 ${state.totalBytes}/${state.maximumBytes} bytes`, `Open shortcut: ${state.shortcut ?? "none"}`, `User settings: ${safeTerminalText2(state.settingsPath)}`, ...state.settingsInvalidReason ? [`Settings warning: ${safeTerminalText2(state.settingsInvalidReason)}`] : [] ], hint: "back" }), help: () => ({ kind: "detail", title: "File Context help", lines: [ "Add context snippet opens the project browser. Select lines and press Enter to add and close, or A to add and keep browsing.", "Selected context is attached in order to your next prompt, then cleared together.", "Review selected context opens each exact snapshot before offering removal.", "The configured shortcut and /file-context browse open the browser directly.", "Settings saves the shortcut and reloads Pi; Status shows active limits and configuration.", "Escape goes back. Ctrl+C closes File Context. Cancelling never changes selected context." ], hint: "back" }) }, actions: { add: () => { addRequested = true; return { kind: "close" }; }, review: ({ itemId }) => { selectedQuoteId = itemId; return { kind: "to", screen: "quote" }; }, "open-shortcut": async () => ({ kind: "to", screen: "shortcut" }), "set-shortcut": async ({ ctx: actionCtx, value, signal }) => { const raw = value?.trim() || null; const normalized = raw ? normalizeKeyId(raw) : void 0; if (raw && !normalized) { actionCtx.ui.notify( `Invalid key identifier: ${safeTerminalText2(raw)}. Use a Pi key identifier like ctrl+shift+x.`, "warning" ); return { kind: "stay" }; } const shortcut = normalized ?? null; try { await options.saveShortcut(shortcut, signal); } catch (error) { if (!signal.aborted && options.isCurrent()) { actionCtx.ui.notify( `Could not save File Context settings; the previous shortcut remains: ${safeTerminalText2(formatError4(error))}`, "error" ); } return { kind: "stay" }; } if (signal.aborted || !options.isCurrent()) return { kind: "close" }; actionCtx.ui.notify( shortcut ? `Saved File Context shortcut ${safeTerminalText2(shortcut)}. Reloading Pi\u2026` : "Disabled the File Context shortcut. Reloading Pi\u2026", "info" ); try { await actionCtx.reload(); return { kind: "close" }; } catch (error) { if (!signal.aborted && options.isCurrent()) { actionCtx.ui.notify( `File Context settings were saved, but Pi could not reload; run /reload to apply them: ${safeTerminalText2(formatError4(error))}`, "error" ); } return { kind: "close" }; } }, remove: async ({ signal }) => { const itemId = selectedQuoteId; if (!itemId) return { kind: "back" }; const result = await options.removeQuote(itemId, signal); if (signal.aborted || !options.isCurrent()) return { kind: "close" }; if (result.kind === "missing") { ctx.ui.notify("That snippet is no longer selected. The list was refreshed.", "warning"); return { kind: "stay" }; } ctx.ui.notify( `Removed from next prompt context: ${safeTerminalText2(result.quote.path)} \xB7 lines ${result.quote.startLine}-${result.quote.endLine}.`, "info" ); selectedQuoteId = void 0; return { kind: "back" }; } } }); while (options.isCurrent() && !options.signal.aborted) { addRequested = false; const result = await runMenu(ctx, menu, { getState: () => options.getState(), signal: options.signal, isCurrent: options.isCurrent, onError: (_menuContext, error) => { ctx.ui.notify( `File Context menu failed: ${safeTerminalText2(formatError4(error))}. Selected context was kept; try again.`, "error" ); }, onUnsupportedMode: (_menuContext, mode) => { ctx.ui.notify(`File Context is unavailable in ${mode} mode.`, "warning"); } }); if (!addRequested || result.kind !== "closed") return result; const addResult = await options.addQuote(options.signal); if (!options.isCurrent() || options.signal.aborted) return { kind: "stale" }; if (addResult === "close") return { kind: "closed", reason: "close" }; } return { kind: "stale" }; } function addDisabledReason(state) { if (state.quotes.length >= state.maximumQuotes) { return `The ${state.maximumQuotes}-snippet limit is reached; review selected context first`; } if (state.totalBytes >= state.maximumBytes) { return `The ${formatBytes(state.maximumBytes)} context limit is reached; review selected context first`; } return void 0; } function formatShortcut(shortcut) { return shortcut ? shortcut.toUpperCase() : "Disabled (use /file-context browse)"; } function singleLinePreview(text) { const normalized = text.replaceAll("\r\n", "\n").replaceAll("\r", "\n").split("\n").join(" \u21B5 "); const characters = [...normalized]; if (characters.length === 0) return "(empty)"; return characters.length <= 200 ? normalized : `${characters.slice(0, 199).join("")}\u2026`; } function estimateTokens2(bytes) { return bytes === 0 ? 0 : Math.max(1, Math.ceil(bytes / 4)); } function formatBytes(bytes) { return bytes % 1e3 === 0 ? `${bytes / 1e3} KB` : `${bytes} bytes`; } function safeTerminalText2(text) { return [...text].map((character) => { const code = character.charCodeAt(0); return code <= 31 || code >= 127 && code <= 159 ? `\\x${code.toString(16).padStart(2, "0")}` : character; }).join(""); } function formatError4(error) { return error instanceof Error ? error.message : String(error); } // src/git-context.ts import { execFile } from "node:child_process"; import { realpath as realpath2 } from "node:fs/promises"; import { isAbsolute as isAbsolute2, relative as relative2, resolve as resolve2, sep as sep2 } from "node:path"; import { promisify } from "node:util"; var execFileAsync = promisify(execFile); var GIT_TIMEOUT_MS = 5e3; var GIT_MAX_BUFFER = 11e5; var MAX_HISTORY_ENTRIES = 20; var GitContext = class { constructor(projectRoot, project, statuses) { this.projectRoot = projectRoot; this.project = project; this.statusMap = new Map(statuses); } projectRoot; project; statusMap; get statuses() { return this.statusMap; } async getFileContext(projectPath, signal) { this.assertProjectPath(projectPath); const [blobResult, diffResult] = await Promise.all([ this.run(["rev-parse", "--verify", `HEAD:${this.repositoryPath(projectPath)}`], true, signal), this.run(["diff", "--no-ext-diff", "--no-textconv", "--unified=3", "HEAD", "--", projectPath], true, signal) ]); signal?.throwIfAborted(); return { status: this.statusMap.get(projectPath), blob: blobResult.ok ? blobResult.stdout.trim() || void 0 : void 0, hunks: diffResult.ok ? parseUnifiedDiff(diffResult.stdout) : [] }; } async refreshFileContext(projectPath, signal) { const [fileContext, statusResult] = await Promise.all([ this.getFileContext(projectPath, signal), this.run( [ "-c", "status.relativePaths=true", "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignored=traditional", "--", "." ], true, signal ) ]); signal?.throwIfAborted(); if (!statusResult.ok) return fileContext; const statuses = parseStatuses(statusResult.stdout, this.project.projectPrefix); this.statusMap.clear(); for (const [path, status] of statuses) this.statusMap.set(path, status); this.project.dirty = [...statuses.values()].some((status) => !status.ignored); return { ...fileContext, status: this.statusMap.get(projectPath) }; } async getBlame(projectPath, line, revision, signal) { this.assertProjectPath(projectPath); if (!Number.isSafeInteger(line) || line < 1) throw new Error("Blame line must be positive"); if (revision && !/^[0-9a-f]{40}$/i.test(revision)) throw new Error("Invalid blame revision"); const result = await this.run( [ "blame", "--no-textconv", "--line-porcelain", "-L", `${line},${line}`, ...revision ? [revision] : [], "--", projectPath ], true, signal ); signal?.throwIfAborted(); if (!result.ok) return void 0; return parseBlame(result.stdout); } async getHistory(projectPath, signal) { this.assertProjectPath(projectPath); const result = await this.run( [ "log", `--max-count=${MAX_HISTORY_ENTRIES}`, "--format=%x1e%H%x1f%an%x1f%at%x1f%s", "--name-only", "-z", "--follow", "--", projectPath ], true, signal ); signal?.throwIfAborted(); return result.ok ? parseHistory(result.stdout) : []; } async loadRevision(projectPath, revision, historicalPath, signal) { this.assertProjectPath(projectPath); const normalizedRevision = revision.trim(); if (!normalizedRevision || /[\0\r\n]/.test(normalizedRevision)) { throw new Error("Revision is required"); } const resolved = await this.run( ["rev-parse", "--verify", "--end-of-options", `${normalizedRevision}^{commit}`], true, signal ); if (!resolved.ok || !/^[0-9a-f]{40}$/i.test(resolved.stdout.trim())) { throw new Error(`Unknown Git revision: ${normalizedRevision}`); } signal?.throwIfAborted(); const commit = resolved.stdout.trim().toLowerCase(); const repositoryPath = historicalPath ? this.assertRepositoryPath(historicalPath) : this.repositoryPath(projectPath); const [contentsResult, blobResult] = await Promise.all([ this.run(["show", `${commit}:${repositoryPath}`], true, signal), this.run(["rev-parse", "--verify", `${commit}:${repositoryPath}`], true, signal) ]); signal?.throwIfAborted(); if (!contentsResult.ok) { throw new Error(`${projectPath} does not exist at ${normalizedRevision}`); } if (Buffer.byteLength(contentsResult.stdout, "utf8") > 1e6) { throw new Error(`${projectPath} exceeds 1000000 bytes at ${normalizedRevision}`); } if (contentsResult.stdout.includes("\0")) { throw new Error(`${projectPath} appears to be binary at ${normalizedRevision}`); } return { path: historicalPath ?? projectPath, lines: normalizeLines(contentsResult.stdout), revision: normalizedRevision, commit, blob: blobResult.ok ? blobResult.stdout.trim() || void 0 : void 0 }; } async run(args, allowFailure = false, signal) { return runGit(this.projectRoot, args, allowFailure, signal); } repositoryPath(projectPath) { return `${this.project.projectPrefix}${projectPath}`.replaceAll("\\", "/"); } assertRepositoryPath(repositoryPath) { const normalized = repositoryPath.replaceAll("\\", "/"); if (!normalized || isAbsolute2(normalized) || normalized.includes("\0")) { throw new Error("Invalid historical Git path"); } const candidate = resolve2(this.project.repositoryRoot, normalized); const result = relative2(this.project.repositoryRoot, candidate); if (result === ".." || result.startsWith(`..${sep2}`)) { throw new Error("Historical Git path is outside the repository"); } return normalized; } assertProjectPath(projectPath) { if (!projectPath || isAbsolute2(projectPath) || projectPath.includes("\0")) { throw new Error("Invalid project path"); } const candidate = resolve2(this.projectRoot, projectPath); const result = relative2(this.projectRoot, candidate); if (result === ".." || result.startsWith(`..${sep2}`)) { throw new Error("Git path is outside the project"); } } }; async function createGitContext(root, signal) { signal?.throwIfAborted(); const projectRoot = await realpath2(root); signal?.throwIfAborted(); const repositoryResult = await runGit(projectRoot, ["rev-parse", "--show-toplevel"], true, signal); if (!repositoryResult.ok) return void 0; const repositoryRoot = stripLineEnding(repositoryResult.stdout); if (!repositoryRoot) return void 0; const [prefixResult, headResult, branchResult, statusResult] = await Promise.all([ runGit(projectRoot, ["rev-parse", "--show-prefix"], true, signal), runGit(projectRoot, ["rev-parse", "--verify", "HEAD"], true, signal), runGit(projectRoot, ["symbolic-ref", "--quiet", "--short", "HEAD"], true, signal), runGit( projectRoot, [ "-c", "status.relativePaths=true", "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignored=traditional", "--", "." ], true, signal ) ]); signal?.throwIfAborted(); const head = headResult.ok ? headResult.stdout.trim().toLowerCase() : "unborn"; const branch = branchResult.ok ? branchResult.stdout.trim() : head === "unborn" ? "unborn" : head.slice(0, 12); const projectPrefix = prefixResult.ok ? stripLineEnding(prefixResult.stdout) : ""; const statuses = statusResult.ok ? parseStatuses(statusResult.stdout, projectPrefix) : /* @__PURE__ */ new Map(); return new GitContext( projectRoot, { repositoryRoot, projectPrefix, branch, head, dirty: [...statuses.values()].some((status) => !status.ignored) }, statuses ); } async function runGit(cwd, args, allowFailure, signal) { try { const { stdout } = await execFileAsync( "git", ["--no-pager", "-c", "core.pager=cat", "-c", "core.fsmonitor=false", ...args], { cwd, encoding: "utf8", timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER, signal, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0", GIT_PAGER: "cat", PAGER: "cat" } } ); return { ok: true, stdout }; } catch (error) { if (signal?.aborted) throw error; if (allowFailure) return { ok: false, stdout: "" }; throw error; } } function parseStatuses(output, projectPrefix) { const statuses = /* @__PURE__ */ new Map(); const records = output.split("\0"); for (let index = 0; index < records.length; index += 1) { const record = records[index]; if (!record || record.length < 4) continue; const code = record.slice(0, 2); const repositoryPath = record.slice(3).replaceAll("\\", "/"); const projectPath = projectPrefix ? repositoryPath.startsWith(projectPrefix) ? repositoryPath.slice(projectPrefix.length) : void 0 : repositoryPath; if (projectPath) statuses.set(projectPath, statusFromCode(code)); if (code.includes("R") || code.includes("C")) index += 1; } return new Map([...statuses].sort(([left], [right]) => compareStrings(left, right))); } function statusFromCode(code) { const conflicted = /^(DD|AU|UD|UA|DU|AA|UU)$/.test(code); const untracked = code === "??"; const ignored = code === "!!"; const staged = !untracked && !ignored && code[0] !== " "; const unstaged = !untracked && !ignored && code[1] !== " "; let label; if (conflicted) label = "conflicted"; else if (untracked) label = "untracked"; else if (ignored) label = "ignored"; else { const character = unstaged ? code[1] : code[0]; const action = character === "A" ? "added" : character === "D" ? "deleted" : character === "R" ? "renamed" : character === "C" ? "copied" : character === "T" ? "type changed" : "modified"; const location = staged && unstaged ? "staged + unstaged" : staged ? "staged" : "unstaged"; label = `${action} (${location})`; } return { code, label, staged, unstaged, untracked, ignored, conflicted }; } function parseUnifiedDiff(output) { const hunks = []; let current; let newLine = 0; for (const line of output.replaceAll("\r\n", "\n").split("\n")) { const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/.exec(line); if (match) { current = { header: line, oldStart: Number(match[1]), oldCount: Number(match[2] ?? 1), newStart: Number(match[3]), newCount: Number(match[4] ?? 1), lines: [line], changedLines: [] }; newLine = current.newStart; hunks.push(current); continue; } if (!current) continue; current.lines.push(line); if (line.startsWith("+") && !line.startsWith("+++")) { current.changedLines.push(newLine); newLine += 1; } else if (!line.startsWith("-") && !line.startsWith("\\")) { newLine += 1; } } for (const hunk of hunks) { while (hunk.lines.at(-1) === "") hunk.lines.pop(); } return hunks; } function parseBlame(output) { const lines = output.split("\n"); const commit = lines[0]?.split(" ")[0]; if (!commit) return void 0; const field = (name) => lines.find((line) => line.startsWith(`${name} `))?.slice(name.length + 1); const authorTime = Number(field("author-time")); return { commit, author: field("author") ?? "Unknown", authorTime: Number.isFinite(authorTime) ? authorTime : void 0, summary: field("summary") ?? "", committed: !/^0+$/.test(commit) }; } function parseHistory(output) { return output.split("").filter(Boolean).flatMap((record) => { const [metadata = "", ...pathRecords] = record.split("\0"); const [commit, author, authorTime, ...summaryParts] = metadata.replace(/^\n+/, "").split(""); const path = pathRecords.map((value) => value.replace(/^\n+/, "")).find(Boolean); const time = Number(authorTime); return commit && author && path && Number.isFinite(time) ? [{ commit, author, authorTime: time, summary: summaryParts.join(""), path }] : []; }); } function compareStrings(left, right) { return left < right ? -1 : left > right ? 1 : 0; } function stripLineEnding(value) { return value.endsWith("\r\n") ? value.slice(0, -2) : value.endsWith("\n") ? value.slice(0, -1) : value; } function normalizeLines(contents) { if (contents === "") return []; const lines = contents.replaceAll("\r\n", "\n").replaceAll("\r", "\n").split("\n"); if (lines.at(-1) === "") lines.pop(); return lines; } // src/file-context.ts var WIDGET_KEY = "file-context"; var DEFAULT_MAX_FILES = 5e3; var DEFAULT_MAX_BYTES = 1e6; var MAX_QUOTE_BYTES = 5e4; var MAX_QUOTE_LINES = 500; var MAX_PENDING_QUOTES = 8; var MAX_PENDING_QUOTE_BYTES = 1e5; var IGNORED_DIRECTORIES = /* @__PURE__ */ new Set([ ".git", ".hg", ".svn", ".next", "build", "coverage", "dist", "node_modules", "target" ]); async function discoverProjectFiles(root, options = {}) { const maxFiles = Math.max(1, options.maxFiles ?? DEFAULT_MAX_FILES); const files = []; options.signal?.throwIfAborted(); const canonicalRoot = await realpath3(root); const directories = [{ directory: canonicalRoot, prefix: "" }]; options.signal?.throwIfAborted(); for (let index = 0; index < directories.length && files.length < maxFiles; index += 1) { options.signal?.throwIfAborted(); const current = directories[index]; if (!current) break; const { directory, prefix } = current; const entries = (await readdir(directory, { withFileTypes: true })).sort( (left, right) => compareStrings2(left.name, right.name) ); options.signal?.throwIfAborted(); for (const entry of entries) { options.signal?.throwIfAborted(); if (IGNORED_DIRECTORIES.has(entry.name) || entry.isSymbolicLink()) continue; const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; const absolutePath = resolve3(directory, entry.name); if (entry.isDirectory()) { directories.push({ directory: absolutePath, prefix: relativePath }); } else if (entry.isFile()) { files.push(relativePath); if (files.length >= maxFiles) break; } } } options.signal?.throwIfAborted(); return files.sort(compareStrings2); } async function loadProjectTextFile(root, projectPath, options = {}) { options.signal?.throwIfAborted(); if (!projectPath || isAbsolute3(projectPath)) throw new Error("File path is outside the project"); const canonicalRoot = await realpath3(root); options.signal?.throwIfAborted(); const candidate = resolve3(canonicalRoot, projectPath); if (!isInside2(canonicalRoot, candidate)) throw new Error("File path is outside the project"); let canonicalFile; try { canonicalFile = await realpath3(candidate); } catch (error) { throw new Error(`Cannot open ${projectPath}: ${formatError5(error)}`); } options.signal?.throwIfAborted(); if (!isInside2(canonicalRoot, canonicalFile)) throw new Error("File path is outside the project"); const info = await lstat2(canonicalFile); options.signal?.throwIfAborted(); if (!info.isFile()) throw new Error(`${projectPath} is not a regular file`); const maxBytes = Math.max(1, options.maxBytes ?? DEFAULT_MAX_BYTES); if (info.size > maxBytes) throw new Error(`${projectPath} exceeds ${maxBytes} bytes`); await options.beforeOpen?.(); options.signal?.throwIfAborted(); let file; try { file = await open2(canonicalFile, constants2.O_RDONLY | (constants2.O_NOFOLLOW ?? 0) | (constants2.O_NONBLOCK ?? 0)); } catch (error) { throw new Error(`Cannot safely open ${projectPath}: ${formatError5(error)}`); } try { options.signal?.throwIfAborted(); const openedInfo = await file.stat(); options.signal?.throwIfAborted(); if (!openedInfo.isFile()) throw new Error(`${projectPath} is not a regular file`); if (openedInfo.dev !== info.dev || openedInfo.ino !== info.ino) { throw new Error(`${projectPath} changed while it was being opened safely`); } if (openedInfo.size > maxBytes) throw new Error(`${projectPath} exceeds ${maxBytes} bytes`); const buffer = Buffer.alloc(maxBytes + 1); let offset = 0; while (offset < buffer.length) { options.signal?.throwIfAborted(); const { bytesRead } = await file.read(buffer, offset, buffer.length - offset, offset); options.signal?.throwIfAborted(); if (bytesRead === 0) break; offset += bytesRead; } if (offset > maxBytes) throw new Error(`${projectPath} exceeds ${maxBytes} bytes`); const contents = buffer.subarray(0, offset); if (contents.includes(0)) throw new Error(`${projectPath} appears to be binary`); return { path: projectPath.replaceAll("\\", "/"), lines: normalizeTextLines(contents.toString("utf8")) }; } finally { await file.close(); } } function createFileQuote(path, lines, anchorIndex, cursorIndex, git) { if (lines.length === 0) throw new Error("Cannot quote an empty file"); const startIndex = Math.max(0, Math.min(anchorIndex, cursorIndex, lines.length - 1)); const endIndex = Math.max(0, Math.min(Math.max(anchorIndex, cursorIndex), lines.length - 1)); const text = lines.slice(startIndex, endIndex + 1).join("\n"); return createFileQuoteSnapshot(path, startIndex + 1, endIndex + 1, text, git); } function createFileQuoteSnapshot(path, startLine, endLine, text, git) { if (!Number.isSafeInteger(startLine) || !Number.isSafeInteger(endLine) || startLine < 1) { throw new Error("Quote lines must be positive integers"); } if (endLine < startLine) throw new Error("Quote end line precedes its start line"); if (text.split("\n").length > MAX_QUOTE_LINES) { throw new Error(`Quote exceeds ${MAX_QUOTE_LINES} lines`); } if (Buffer.byteLength(text, "utf8") > MAX_QUOTE_BYTES) { throw new Error(`Quote exceeds ${MAX_QUOTE_BYTES} bytes`); } return { path, startLine, endLine, text, ...git ? { git: { head: git.head, ...git.branch !== void 0 ? { branch: git.branch } : {}, ...git.status !== void 0 ? { status: git.status } : {}, ...git.revision !== void 0 ? { revision: git.revision } : {}, ...git.blob !== void 0 ? { blob: git.blob } : {}, contentSha256: createHash("sha256").update(text, "utf8").digest("hex"), ...git.source !== void 0 ? { source: git.source } : {}, ...git.base !== void 0 ? { base: git.base } : {} } } : {} }; } function appendPendingQuote(current, quote) { if (current.length >= MAX_PENDING_QUOTES) { throw new Error(`File Context supports at most ${MAX_PENDING_QUOTES} pending quotes`); } const totalBytes = [...current, quote].reduce((total, item) => total + Buffer.byteLength(item.text, "utf8"), 0); if (totalBytes > MAX_PENDING_QUOTE_BYTES) { throw new Error(`Pending quotes exceed ${MAX_PENDING_QUOTE_BYTES} bytes`); } return [...current, quote]; } function formatQuoteContext(quotes) { const blocks = quotes.map((quote) => { const path = escapeXml(quote.path); const text = escapeXml(quote.text); const attributes = [ `path="${path}"`, `lines="${quote.startLine}-${quote.endLine}"`, ...formatGitAttributes(quote.git) ].join(" "); return ` ${text} `; }); const description = quotes.length === 1 ? "The user intentionally selected the file excerpt above." : "The user intentionally selected the file excerpts above."; return `${blocks.join("\n\n")} ${description}`; } async function registerFileQuoteExtension(pi, dependencies = {}) { const settingsPath = dependencies.settingsPath ?? fileContextSettingsPath(); const loadSettings = dependencies.loadSettings ?? (() => loadFileContextSettings(settingsPath)); const updateSettings = dependencies.updateSettings ?? updateFileContextSettings; let loadedSettings = await loadSettings(); const discoverFiles = dependencies.discoverFiles ?? discoverProjectFiles; const createGit = dependencies.createGit ?? createGitContext; let pendingQuotes = []; let nextPendingQuoteId = 1; let activeSessionManager; let sessionGeneration = 0; let sessionController = new AbortController(); const activeExplorers = /* @__PURE__ */ new Set(); let activeExplorerLaunch; let activeMenuLaunch; const updatePendingWidget = (ctx) => { if (!ctx.hasUI) return; if (pendingQuotes.length === 0) { ctx.ui.setWidget(WIDGET_KEY, void 0); return; } const totalBytes = pendingQuotes.reduce((total, item) => total + Buffer.byteLength(item.quote.text, "utf8"), 0); ctx.ui.setWidget(WIDGET_KEY, [ ctx.ui.theme.fg( "accent", `Next prompt context \xB7 ${pendingQuotes.length} ${pendingQuotes.length === 1 ? "snippet" : "snippets"} \xB7 ~${estimateTokens3(totalBytes)} tokens \xB7 /file-context to review` ), ...pendingQuotes.map( ({ quote }, index) => ctx.ui.theme.fg( "muted", `${index + 1}. ${escapeTerminalControls3(quote.path)} \xB7 lines ${quote.startLine}-${quote.endLine} \xB7 ~${estimateTokens3(Buffer.byteLength(quote.text, "utf8"))} tokens` ) ) ]); }; const clearPending = (ctx) => { pendingQuotes = []; nextPendingQuoteId = 1; updatePendingWidget(ctx); }; const validatePending = (quote) => { appendPendingQuote( pendingQuotes.map((item) => item.quote), quote ); }; const appendPending = (quote, ctx) => { validatePending(quote); pendingQuotes = [...pendingQuotes, { id: `quote-${nextPendingQuoteId}`, quote }]; nextPendingQuoteId += 1; updatePendingWidget(ctx); }; const isCurrentSession = (owner, generation) => owner === activeSessionManager && generation === sessionGeneration; const menuQuote = ({ id, quote }) => ({ id, path: quote.path, startLine: quote.startLine, endLine: quote.endLine, text: quote.text }); const cancelExplorers = () => { activeExplorerLaunch = void 0; for (const explorer of activeExplorers) { explorer.controller.abort(new DOMException("File Context explorer closed", "AbortError")); explorer.component?.dispose(); } activeExplorers.clear(); }; const runExplorer = async (ctx, options = {}) => { if (ctx.mode !== "tui") { rejectCommand(ctx, "File Context requires Pi's interactive TUI."); return "close"; } const owner = ctx.sessionManager; const generation = sessionGeneration; const activeExplorer = { controller: new AbortController() }; const { controller } = activeExplorer; const flowSignal = options.signal ? AbortSignal.any([controller.signal, options.signal]) : controller.signal; activeExplorers.add(activeExplorer); try { const { runCustomInteraction, runTask } = await import("@narumitw/pi-tui-kit"); if (!isCurrentSession(owner, generation) || flowSignal.aborted) return "close"; const task = await runTask(ctx, { label: "Scanning project files\u2026", signal: flowSignal, isCurrent: () => isCurrentSession(owner, generation) && !flowSignal.aborted, task: ({ signal }) => Promise.all([discoverFiles(ctx.cwd, { signal }), createGit(ctx.cwd, signal)]), onError: (_taskContext, error) => { ctx.ui.notify( `File Context could not scan project files: ${escapeTerminalControls3(formatError5(error))}. Open File Context to retry.`, "error" ); } }); if (task.kind === "cancelled" || task.kind === "error") { return options.menuOwned ? "stay" : "close"; } if (task.kind === "stale") return "close"; const discovery = task.value; if (!isCurrentSession(owner, generation) || flowSignal.aborted) return "close"; const [files, gitContext] = discovery; if (files.length === 0) { ctx.ui.notify("File Context found no project files. Choose Add context snippet to retry.", "warning"); return options.menuOwned ? "stay" : "close"; } const interaction = await runCustomInteraction(ctx, { signal: flowSignal, isCurrent: () => isCurrentSession(owner, generation), onError: () => { }, create: ({ tui, theme, keybindings, signal: interactionSignal, complete }) => { const component = new FileQuoteExplorer({ tui, theme, keybindings, files, cwd: ctx.cwd, loadFile: (path, signal) => loadProjectTextFile(ctx.cwd, path, { signal: signal ? AbortSignal.any([signal, interactionSignal]) : interactionSignal }), editFile: (path, signal) => editProjectFileInExternalEditor({ root: ctx.cwd, projectPath: path, tui, projectTrusted: ctx.isProjectTrusted(), signal: signal ? AbortSignal.any([signal, interactionSignal]) : interactionSignal, isCurrent: () => isCurrentSession(owner, generation) && !interactionSignal.aborted }), gitContext, rootNavigation: options.menuOwned, getSelectedContextState: () => ({ count: pendingQuotes.length, totalBytes: pendingQuotes.reduce((total, item) => total + Buffer.byteLength(item.quote.text, "utf8"), 0), maximumCount: MAX_PENDING_QUOTES, maximumBytes: MAX_PENDING_QUOTE_BYTES, maximumSnippetLines: MAX_QUOTE_LINES, maximumSnippetBytes: MAX_QUOTE_BYTES }), validateQuote: validatePending, onAddAndContinue: (quote) => { if (!isCurrentSession(owner, generation) || interactionSignal.aborted) { throw new DOMException("File Context session replaced", "AbortError"); } appendPending(quote, ctx); try { ctx.ui.notify( `Added to next prompt context: ${escapeTerminalControls3(quote.path)} \xB7 lines ${quote.startLine}-${quote.endLine}.`, "info" ); } catch { } }, done: complete }); activeExplorer.component = component; return component; } }); if (interaction.kind === "error") throw interaction.error; const result = interaction.kind === "completed" ? interaction.value : void 0; if (!isCurrentSession(owner, generation) || flowSignal.aborted) return "close"; if (result?.kind === "quote") { appendPending(result.quote, ctx); return "close"; } if (result?.kind === "reference") { ctx.ui.pasteToEditor(formatFileReference(result.path)); return "close"; } return result?.kind === "back" ? "stay" : "close"; } catch (error) { if (!isCurrentSession(owner, generation) || flowSignal.aborted || isAbortError4(error)) { return "close"; } try { ctx.ui.notify( `File Context failed: ${escapeTerminalControls3(formatError5(error))}. Open File Context to retry.`, "error" ); } catch { } return options.menuOwned ? "stay" : "close"; } finally { activeExplorers.delete(activeExplorer); } }; const openExplorer = (ctx) => { if (activeExplorerLaunch) return activeExplorerLaunch.promise; if (activeMenuLaunch) return activeMenuLaunch.promise; const launch = { promise: runExplorer(ctx).then(() => void 0) }; activeExplorerLaunch = launch; const clearLaunch = () => { if (activeExplorerLaunch === launch) activeExplorerLaunch = void 0; }; void launch.promise.then(clearLaunch, clearLaunch); return launch.promise; }; const registeredOpenShortcut = loadedSettings.settings.openShortcut; if (registeredOpenShortcut) { pi.registerShortcut(registeredOpenShortcut, { description: "Open File Context", handler: openExplorer }); } const openMenu = (ctx, start = "main") => { if (ctx.mode !== "tui") { rejectCommand(ctx, "File Context requires Pi's interactive TUI."); return Promise.resolve(); } if (activeMenuLaunch) return activeMenuLaunch.promise; if (activeExplorerLaunch) return activeExplorerLaunch.promise; if (start === "remove" && pendingQuotes.length === 0) { ctx.ui.notify("File Context has no context selected for the next prompt.", "warning"); return Promise.resolve(); } const owner = ctx.sessionManager; const generation = sessionGeneration; const ownerController = sessionController; const isCurrent = () => isCurrentSession(owner, generation) && ownerController === sessionController && !ownerController.signal.aborted; const promise = showFileContextMenu(ctx, { start, signal: ownerController.signal, isCurrent, getState: () => { if (!isCurrent()) throw new DOMException("File Context session replaced", "AbortError"); const quotes = pendingQuotes.map(menuQuote); return { quotes, shortcut: loadedSettings.settings.openShortcut, maximumQuotes: MAX_PENDING_QUOTES, maximumBytes: MAX_PENDING_QUOTE_BYTES, totalBytes: quotes.reduce((total, quote) => total + Buffer.byteLength(quote.text, "utf8"), 0), settingsPath, settingsInvalidReason: loadedSettings.invalidReason }; }, addQuote: (signal) => runExplorer(ctx, { menuOwned: true, signal }), saveShortcut: async (shortcut, signal) => { if (!isCurrent() || signal.aborted) { throw new DOMException("File Context session replaced", "AbortError"); } const saved = await updateSettings(shortcut, { settingsPath, signal }); if (!isCurrent() || signal.aborted) return; loadedSettings = { settings: saved }; }, removeQuote: (id, signal) => { if (!isCurrent() || signal.aborted) return { kind: "missing" }; const index = pendingQuotes.findIndex((item) => item.id === id); const selected = pendingQuotes[index]; if (!selected) return { kind: "missing" }; pendingQuotes = pendingQuotes.filter((_item, itemIndex) => itemIndex !== index); updatePendingWidget(ctx); return { kind: "removed", quote: menuQuote(selected), remaining: pendingQuotes.length }; } }).then(() => void 0); const launch = { promise }; activeMenuLaunch = launch; const clearLaunch = () => { if (activeMenuLaunch === launch) activeMenuLaunch = void 0; }; void promise.then(clearLaunch, clearLaunch); return promise; }; const handleFileContextCommand = async (args, ctx) => { const normalized = args.trim().toLowerCase(); if (!normalized) { await openMenu(ctx); return; } if (normalized === "browse") { await openExplorer(ctx); return; } if (normalized === "remove") { await openMenu(ctx, "remove"); return; } rejectCommand(ctx, "Usage: /file-context [browse|remove]"); }; pi.registerCommand("file-context", { description: "Open the File Context menu", getArgumentCompletions: (prefix) => { const normalized = prefix.trimStart().toLowerCase(); const completions = [ { value: "browse", label: "browse", description: "Open the file browser directly" }, { value: "remove", label: "remove", description: "Review selected context directly" } ].filter(({ value }) => value.startsWith(normalized)); return completions.length > 0 ? completions : null; }, handler: handleFileContextCommand }); pi.on("session_start", async (_event, ctx) => { sessionController.abort(new DOMException("File Context session replaced", "AbortError")); sessionController = new AbortController(); const ownerController = sessionController; activeMenuLaunch = void 0; cancelExplorers(); activeSessionManager = ctx.sessionManager; const generation = ++sessionGeneration; clearPending(ctx); const refreshedSettings = await loadSettings(); if (!isCurrentSession(ctx.sessionManager, generation) || ownerController !== sessionController || ownerController.signal.aborted) { return; } loadedSettings = refreshedSettings; if (loadedSettings.warning && ctx.hasUI) { ctx.ui.notify(escapeTerminalControls3(loadedSettings.warning), "warning"); } }); pi.on("before_agent_start", (_event, ctx) => { if (ctx.sessionManager !== activeSessionManager || pendingQuotes.length === 0) return; const quotes = pendingQuotes.map((item) => item.quote); clearPending(ctx); return { message: { customType: "file-context-quotes", content: formatQuoteContext(quotes), display: false } }; }); pi.on("session_shutdown", async (_event, ctx) => { if (ctx.sessionManager !== activeSessionManager) return; sessionController.abort(new DOMException("File Context session shut down", "AbortError")); activeMenuLaunch = void 0; cancelExplorers(); clearPending(ctx); await awaitFileContextSettingsWrites(settingsPath); }); } async function fileQuoteExtension(pi) { await registerFileQuoteExtension(pi); } function normalizeTextLines(contents) { if (contents === "") return []; const lines = contents.replaceAll("\r\n", "\n").replaceAll("\r", "\n").split("\n"); if (lines.at(-1) === "") lines.pop(); return lines; } function formatFileReference(path) { const escaped = path.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); return /\s|["\\]/.test(path) ? `@"${escaped}" ` : `@${path} `; } function compareStrings2(left, right) { return left < right ? -1 : left > right ? 1 : 0; } function estimateTokens3(bytes) { return Math.max(1, Math.ceil(bytes / 4)); } function isInside2(root, candidate) { const result = relative3(root, candidate); return result === "" || !result.startsWith(`..${sep3}`) && result !== ".." && !isAbsolute3(result); } function formatGitAttributes(git) { if (!git) return []; return [ ["git_head", git.head], ["git_branch", git.branch], ["git_status", git.status], ["git_revision", git.revision], ["git_blob", git.blob], ["content_sha256", git.contentSha256], ["source", git.source], ["git_base", git.base] ].flatMap(([name, value]) => value ? [`${name}="${escapeXml(value)}"`] : []); } function escapeXml(value) { return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); } function escapeTerminalControls3(text) { return [...text].map((character) => { const code = character.charCodeAt(0); return code <= 31 || code >= 127 && code <= 159 ? `\\x${code.toString(16).padStart(2, "0")}` : character; }).join(""); } function rejectCommand(ctx, message) { if (ctx.hasUI) { ctx.ui.notify(message, "warning"); return; } throw new Error(message); } function isAbortError4(error) { return error instanceof Error && error.name === "AbortError"; } function formatError5(error) { return error instanceof Error ? error.message : String(error); } export { fileQuoteExtension as default }; //# sourceMappingURL=index.ts.map