import { getAgentDir, type BuildSystemPromptOptions, type ExtensionAPI, type KeybindingsManager, type Theme, } from "@earendil-works/pi-coding-agent"; import { type Component, type Focusable, Input, Key, matchesKey, truncateToWidth, type TUI, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui"; import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, normalize, resolve, sep } from "node:path"; const CONFIG_VERSION = 1; const CONFIG_FILE_NAME = "context-control.json"; const WIDE_LAYOUT_MIN_WIDTH = 92; const PROJECT_CONTEXT_HEADER = "\n\n\n\nProject-specific instructions and guidelines:\n\n"; const PROJECT_CONTEXT_FOOTER = "\n"; type ContextFile = NonNullable[number]; type ContextScope = "User" | "Inherited" | "Current project"; type PanelFocus = "list" | "preview"; type NarrowView = "list" | "preview"; type FlashKind = "success" | "warning" | "error"; interface ContextControlConfig { version: typeof CONFIG_VERSION; disabledPaths: string[]; } interface ContextListItem { path: string; label: string; scope: ContextScope; content: string; } interface ListGroupRow { type: "group"; key: string; scope: ContextScope; count: number; collapsed: boolean; } interface ListItemRow { type: "item"; item: ContextListItem; } type ListRow = ListGroupRow | ListItemRow; interface PreviewCache { path: string; width: number; lines: string[]; } interface ContextUndoEntry { path: string; label: string; previousDisabled: boolean; } export type ContextControlPanelResult = { action: "close" }; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function isContextControlConfig(value: unknown): value is ContextControlConfig { return ( isRecord(value) && value.version === CONFIG_VERSION && Array.isArray(value.disabledPaths) && value.disabledPaths.every((path) => typeof path === "string") ); } export function canonicalPath(filePath: string, cwd = process.cwd()): string { const absolutePath = normalize(resolve(cwd, filePath)); try { return realpathSync.native(absolutePath); } catch { return absolutePath; } } function displayPath(filePath: string): string { const home = homedir(); if (filePath === home) return "~"; if (filePath.startsWith(`${home}${sep}`)) return `~${filePath.slice(home.length)}`; return filePath; } function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } function formatCount(count: number): string { if (count < 1000) return String(count); if (count < 1_000_000) return `${(count / 1000).toFixed(count < 10_000 ? 1 : 0)}k`; return `${(count / 1_000_000).toFixed(1)}m`; } function estimateTokens(content: string): number { let tokens = 0; for (const character of content) { tokens += (character.codePointAt(0) ?? 0) <= 127 ? 0.25 : 1; } return Math.ceil(tokens); } function lineCount(content: string): number { if (content.length === 0) return 0; return content.replace(/\r\n/g, "\n").split("\n").length; } export function renderProjectContext(contextFiles: readonly ContextFile[]): string { if (contextFiles.length === 0) return ""; let section = PROJECT_CONTEXT_HEADER; for (const contextFile of contextFiles) { section += `\n${contextFile.content}\n\n\n`; } return `${section}${PROJECT_CONTEXT_FOOTER}`; } export function filterProjectContext( systemPrompt: string, contextFiles: readonly ContextFile[], disabledPaths: ReadonlySet, cwd = process.cwd(), ): string { const enabledFiles = contextFiles.filter((file) => !disabledPaths.has(canonicalPath(file.path, cwd))); if (enabledFiles.length === contextFiles.length) return systemPrompt; const originalSection = renderProjectContext(contextFiles); const sectionIndex = systemPrompt.lastIndexOf(originalSection); if (sectionIndex === -1) return systemPrompt; const enabledSection = renderProjectContext(enabledFiles); return `${systemPrompt.slice(0, sectionIndex)}${enabledSection}${systemPrompt.slice(sectionIndex + originalSection.length)}`; } export function readConfig(configPath: string): { disabledPaths: Set; error?: string } { try { const parsed: unknown = JSON.parse(readFileSync(configPath, "utf8")); if (!isContextControlConfig(parsed)) { return { disabledPaths: new Set(), error: `Invalid context control config: ${configPath}`, }; } return { disabledPaths: new Set(parsed.disabledPaths.map((path) => canonicalPath(path))), }; } catch (error) { if (isRecord(error) && error.code === "ENOENT") return { disabledPaths: new Set() }; return { disabledPaths: new Set(), error: `Could not read context control config: ${configPath}`, }; } } export function writeConfig(configPath: string, disabledPaths: ReadonlySet): void { const config: ContextControlConfig = { version: CONFIG_VERSION, disabledPaths: [...disabledPaths].sort(), }; const temporaryPath = `${configPath}.${process.pid}.tmp`; mkdirSync(dirname(configPath), { recursive: true }); writeFileSync(temporaryPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); renameSync(temporaryPath, configPath); } export function clonePaths(paths: ReadonlySet): Set { return new Set(paths); } export function replacePaths(target: Set, source: ReadonlySet): void { target.clear(); for (const path of source) target.add(path); } export function pathsEqual(left: ReadonlySet, right: ReadonlySet): boolean { if (left.size !== right.size) return false; for (const path of left) { if (!right.has(path)) return false; } return true; } export function changedPathCount(left: ReadonlySet, right: ReadonlySet): number { let count = 0; for (const path of new Set([...left, ...right])) { if (left.has(path) !== right.has(path)) count += 1; } return count; } function fuzzyMatchScore(value: string, query: string): number | undefined { const haystack = [...value.toLowerCase()]; const needle = [...query.toLowerCase()]; if (needle.length === 0) return Number.MAX_SAFE_INTEGER; let queryIndex = 0; let firstMatch = -1; let lastMatch = -1; for (let index = 0; index < haystack.length; index++) { if (haystack[index] !== needle[queryIndex]) continue; if (firstMatch < 0) firstMatch = index; lastMatch = index; queryIndex += 1; if (queryIndex !== needle.length) continue; const window = lastMatch - firstMatch + 1 - needle.length; return Math.max(0, window) - (firstMatch === 0 ? 100 : 0); } return undefined; } function contextSearchScore(item: ContextListItem, query: string): number | undefined { const scores = [fuzzyMatchScore(item.label, query), fuzzyMatchScore(item.path, query)].filter( (score): score is number => score !== undefined, ); return scores.length > 0 ? Math.min(...scores) : undefined; } export class ContextControlPanel implements Component, Focusable { readonly #tui: TUI; readonly #theme: Theme; readonly #keybindings: KeybindingsManager; readonly #items: ContextListItem[]; readonly #initialDisabledPaths: Set; readonly #disabledPaths: Set; readonly #onApply: (disabledPaths: Set) => void; readonly #onDone: (result: ContextControlPanelResult) => void; #query = ""; #filterEditing = false; #filterInput = new Input(); #queryBeforeEdit = ""; #selectedIndexBeforeFilter = 0; #selectedIndex = 0; #focus: PanelFocus = "list"; #narrowView: NarrowView = "list"; #previewOffset = 0; #lastWidth = 0; #lastPreviewLineCount = 0; #lastPreviewViewportHeight = 1; #previewCache: PreviewCache | undefined; #flash: { kind: FlashKind; text: string } | undefined; #confirmDiscard = false; #stateGuideOpen = false; #undoStack: ContextUndoEntry[] = []; #collapsedGroupKeys = new Set(); #focused = false; get focused(): boolean { return this.#focused; } set focused(value: boolean) { this.#focused = value; this.#filterInput.focused = value && this.#filterEditing; } constructor(options: { tui: TUI; theme: Theme; keybindings: KeybindingsManager; items: ContextListItem[]; disabledPaths: ReadonlySet; onApply: (disabledPaths: Set) => void; onDone: (result: ContextControlPanelResult) => void; }) { this.#tui = options.tui; this.#theme = options.theme; this.#keybindings = options.keybindings; this.#items = options.items; this.#initialDisabledPaths = clonePaths(options.disabledPaths); this.#disabledPaths = clonePaths(options.disabledPaths); this.#onApply = options.onApply; this.#onDone = options.onDone; this.#selectedIndex = this.#firstItemRowIndex(this.#listRows()); this.#selectedIndexBeforeFilter = this.#selectedIndex; } invalidate(): void { this.#previewCache = undefined; this.#filterInput.invalidate(); } render(width: number): string[] { if (this.#confirmDiscard) { this.#lastWidth = width; return this.#renderDiscardDialog(width); } if (this.#stateGuideOpen) { this.#lastWidth = width; return this.#renderStateGuide(width); } const wasWide = this.#lastWidth >= WIDE_LAYOUT_MIN_WIDTH; const isWide = width >= WIDE_LAYOUT_MIN_WIDTH; if (this.#lastWidth > 0 && wasWide !== isWide) { if (isWide) this.#focus = this.#narrowView; else this.#narrowView = this.#focus; } this.#lastWidth = width; if (width < 4) return [truncateToWidth("Context", width, "")]; return isWide ? this.#renderWide(width) : this.#renderNarrow(width); } handleInput(data: string): void { if (this.#confirmDiscard) { this.#handleDiscardInput(data); this.#requestRender(); return; } if (this.#stateGuideOpen) { this.#handleStateGuideInput(data); this.#requestRender(); return; } if (matchesKey(data, Key.ctrl("s"))) { this.#apply(); return; } const wide = this.#lastWidth >= WIDE_LAYOUT_MIN_WIDTH; if (this.#filterEditing && this.#handleFilterInput(data, wide)) { this.#requestRender(); return; } if (this.#keybindings.matches(data, "tui.select.cancel")) { if (!wide && this.#narrowView === "preview") { this.#narrowView = "list"; this.#focus = "list"; } else if (this.#query.length > 0) { this.#clearFilter(); } else if (this.#isDirty()) { this.#confirmDiscard = true; } else { this.#onDone({ action: "close" }); return; } this.#requestRender(); return; } if (data === "/") { this.#beginFilterEditing(); this.#requestRender(); return; } if (data === "?") { this.#stateGuideOpen = true; this.#flash = undefined; this.#requestRender(); return; } const listActive = wide ? this.#focus === "list" : this.#narrowView === "list"; const groupSelected = listActive && this.#currentGroup() !== undefined; if (matchesKey(data, Key.left) || data === "h") { if (groupSelected) this.#setCurrentGroupCollapsed(true); else this.#moveFocus(-1, wide); this.#requestRender(); return; } if (matchesKey(data, Key.right) || data === "l") { if (groupSelected) this.#setCurrentGroupCollapsed(false); else this.#moveFocus(1, wide); this.#requestRender(); return; } if (wide && matchesKey(data, Key.tab)) { this.#moveFocus(1, wide); this.#requestRender(); return; } const previewActive = wide ? this.#focus === "preview" : this.#narrowView === "preview"; if (previewActive) this.#handlePreviewInput(data, wide); else this.#handleListInput(data, wide); this.#requestRender(); } #requestRender(): void { this.#tui.requestRender(); } #beginFilterEditing(): void { this.#filterEditing = true; this.#queryBeforeEdit = this.#query; this.#selectedIndexBeforeFilter = this.#selectedIndex; this.#filterInput = new Input(); if (this.#query) this.#filterInput.handleInput(this.#query); this.#filterInput.focused = this.#focused; this.#focus = "list"; this.#narrowView = "list"; this.#flash = undefined; } #commitFilterEditing(): void { this.#filterEditing = false; this.#filterInput.focused = false; this.#queryBeforeEdit = this.#query; this.#selectedIndexBeforeFilter = this.#selectedIndex; this.#flash = undefined; } #cancelFilterEditing(): void { this.#query = this.#queryBeforeEdit; this.#filterEditing = false; this.#filterInput.focused = false; this.#resetFilterNavigation(this.#selectedIndexBeforeFilter); } #clearFilter(): void { this.#query = ""; this.#queryBeforeEdit = ""; this.#resetFilterNavigation(); } #syncFilterInput(): void { const query = this.#filterInput.getValue(); if (query === this.#query) return; this.#query = query; this.#resetFilterNavigation(); } #resetFilterNavigation(selectedIndex?: number): void { const rows = this.#listRows(); const lastIndex = Math.max(0, rows.length - 1); this.#selectedIndex = selectedIndex === undefined ? this.#firstItemRowIndex(rows) : Math.max(0, Math.min(selectedIndex, lastIndex)); this.#previewOffset = 0; this.#previewCache = undefined; this.#flash = undefined; } #handleFilterInput(data: string, wide: boolean): boolean { if (this.#keybindings.matches(data, "tui.select.cancel")) { this.#cancelFilterEditing(); } else if (this.#keybindings.matches(data, "tui.select.confirm")) { this.#commitFilterEditing(); } else if (this.#keybindings.matches(data, "tui.select.up")) { this.#moveSelection(-1); } else if (this.#keybindings.matches(data, "tui.select.down")) { this.#moveSelection(1); } else if (this.#keybindings.matches(data, "tui.select.pageUp")) { this.#setSelection(this.#selectedIndex - 8); } else if (this.#keybindings.matches(data, "tui.select.pageDown")) { this.#setSelection(this.#selectedIndex + 8); } else if (wide && matchesKey(data, Key.tab)) { this.#commitFilterEditing(); this.#focus = "preview"; } else { this.#filterInput.handleInput(data); this.#syncFilterInput(); } return true; } #isDirty(): boolean { return !pathsEqual(this.#initialDisabledPaths, this.#disabledPaths); } #pendingCount(): number { return changedPathCount(this.#initialDisabledPaths, this.#disabledPaths); } #pendingText(): string { const pending = this.#pendingCount(); return pending > 0 ? `Pending ${pending}` : "No pending changes"; } #apply(): void { if (!this.#isDirty()) { this.#flash = { kind: "success", text: "No pending changes" }; this.#requestRender(); return; } const changeCount = this.#pendingCount(); try { this.#onApply(clonePaths(this.#disabledPaths)); } catch (error) { this.#flash = { kind: "error", text: error instanceof Error ? error.message : "Could not apply Context changes", }; this.#requestRender(); return; } replacePaths(this.#initialDisabledPaths, this.#disabledPaths); this.#undoStack.length = 0; this.#flash = { kind: "success", text: `Applied ${changeCount} Context ${changeCount === 1 ? "change" : "changes"} · No pending changes`, }; this.#requestRender(); } #isPathPending(path: string): boolean { return this.#initialDisabledPaths.has(path) !== this.#disabledPaths.has(path); } #rowStatus(item: ContextListItem): "Included" | "Excluded" | "Pending" { if (this.#isPathPending(item.path)) return "Pending"; return this.#disabledPaths.has(item.path) ? "Excluded" : "Included"; } #stateLabel(item: ContextListItem): "Included" | "Excluded" { return this.#disabledPaths.has(item.path) ? "Excluded" : "Included"; } #sourceStatus(item: ContextListItem): string { if (this.#isPathPending(item.path)) { return this.#disabledPaths.has(item.path) ? "Pending exclusion" : "Pending reset"; } return this.#disabledPaths.has(item.path) ? "Saved exclusion" : "Using default"; } #changeCurrentItem(disabled: boolean): void { const item = this.#currentItem(); if (!item) return; const currentDisabled = this.#disabledPaths.has(item.path); if (currentDisabled === disabled) { this.#flash = { kind: "success", text: `Already ${disabled ? "Excluded" : "Included (default)"} · ${this.#pendingText()}`, }; return; } this.#undoStack.push({ path: item.path, label: item.label, previousDisabled: currentDisabled }); if (disabled) this.#disabledPaths.add(item.path); else this.#disabledPaths.delete(item.path); const pending = this.#pendingCount(); const before = currentDisabled ? "Excluded" : "Included"; const after = disabled ? "Excluded" : "Included (default)"; this.#flash = { kind: pending > 0 ? "warning" : "success", text: `${before} → ${after} · ${this.#pendingText()}`, }; } #toggleCurrentItem(): void { const item = this.#currentItem(); if (!item) return; this.#changeCurrentItem(!this.#disabledPaths.has(item.path)); } #resetCurrentItem(): void { this.#changeCurrentItem(false); } #undoLatestChange(): void { const entry = this.#undoStack.pop(); if (!entry) { this.#flash = { kind: "warning", text: "No Context changes to undo" }; return; } const before = this.#disabledPaths.has(entry.path) ? "Excluded" : "Included"; if (entry.previousDisabled) this.#disabledPaths.add(entry.path); else this.#disabledPaths.delete(entry.path); const after = entry.previousDisabled ? "Excluded" : "Included (default)"; const pending = this.#pendingCount(); this.#flash = { kind: pending > 0 ? "warning" : "success", text: `Undo ${entry.label}: ${before} → ${after} · ${this.#pendingText()}`, }; } #handleDiscardInput(data: string): void { if (data.toLowerCase() === "y") { this.#onDone({ action: "close" }); return; } if ( data.toLowerCase() === "n" || this.#keybindings.matches(data, "tui.select.cancel") || this.#keybindings.matches(data, "tui.select.confirm") ) { this.#confirmDiscard = false; } } #handleStateGuideInput(data: string): void { if (data === "?" || this.#keybindings.matches(data, "tui.select.cancel")) { this.#stateGuideOpen = false; } } #renderStateGuide(width: number): string[] { if (width < 4) return [truncateToWidth("Context state", width, "")]; const innerWidth = width - 2; const lines = this.#topBorder(width, "Context state guide"); for (const line of wrapTextWithAnsi( this.#theme.fg( "muted", "Pi discovers Context files normally. Context Control can include each file or exclude it from future agent prompts.", ), Math.max(1, innerWidth - 2), )) { lines.push(this.#fullLine(line, innerWidth)); } lines.push(this.#border("├", "─", "┤", innerWidth)); lines.push( this.#fullLine( this.#joined( `${this.#theme.fg("accent", "●")} ${this.#theme.fg("text", "Included")}`, this.#theme.fg("muted", "Added to the next agent prompt"), Math.max(0, innerWidth - 2), ), innerWidth, ), ); lines.push( this.#fullLine( this.#joined( `${this.#theme.fg("dim", "○")} ${this.#theme.fg("text", "Excluded")}`, this.#theme.fg("muted", "Removed from prompts; file unchanged"), Math.max(0, innerWidth - 2), ), innerWidth, ), ); lines.push(this.#border("├", "─", "┤", innerWidth)); const help = width >= 76 ? "Space/Shift+Space toggle r included u undo Esc close" : width >= 50 ? "Space toggle r included u undo Esc close" : "Esc close"; lines.push(this.#fullLine(this.#theme.fg("dim", help), innerWidth)); lines.push(this.#border("╰", "─", "╯", innerWidth)); return lines; } #renderDiscardDialog(width: number): string[] { if (width < 4) return [truncateToWidth("Discard changes?", width, "")]; const innerWidth = width - 2; const pending = this.#pendingCount(); const lines = this.#topBorder(width, "Unsaved Context changes"); lines.push( this.#fullLine( this.#theme.fg("warning", `Discard ${pending} pending ${pending === 1 ? "change" : "changes"}?`), innerWidth, ), ); lines.push(this.#fullLine(this.#theme.fg("dim", "Y discard N/Enter/Esc keep editing"), innerWidth)); lines.push(this.#border("╰", "─", "╯", innerWidth)); return lines; } #filteredItems(): ContextListItem[] { const normalizedQuery = this.#query.trim().toLowerCase(); if (!normalizedQuery) return this.#items; return this.#items .map((item, index) => ({ item, index, score: contextSearchScore(item, normalizedQuery) })) .filter( (entry): entry is { item: ContextListItem; index: number; score: number } => entry.score !== undefined, ) .sort((left, right) => left.score - right.score || left.index - right.index) .map((entry) => entry.item); } #buildListRows(items: ContextListItem[]): ListRow[] { const grouped = new Map(); for (const item of items) { const existing = grouped.get(item.scope); if (existing) existing.push(item); else grouped.set(item.scope, [item]); } const rows: ListRow[] = []; const filtering = this.#query.trim().length > 0; for (const [scope, groupItems] of grouped) { const key = scope; const collapsed = !filtering && this.#collapsedGroupKeys.has(key); rows.push({ type: "group", key, scope, count: groupItems.length, collapsed }); if (collapsed) continue; for (const item of groupItems) rows.push({ type: "item", item }); } return rows; } #listRows(): ListRow[] { return this.#buildListRows(this.#filteredItems()); } #rowKey(row: ListRow): string { return row.type === "group" ? `group:${row.key}` : `item:${row.item.path}`; } #selectedRow(): ListRow | undefined { const rows = this.#listRows(); if (rows.length === 0) return undefined; return rows[Math.max(0, Math.min(this.#selectedIndex, rows.length - 1))]; } #currentItem(): ContextListItem | undefined { const row = this.#selectedRow(); return row?.type === "item" ? row.item : undefined; } #currentGroup(): ListGroupRow | undefined { const row = this.#selectedRow(); return row?.type === "group" ? row : undefined; } #firstItemRowIndex(rows: ListRow[]): number { const index = rows.findIndex((row) => row.type === "item"); return index >= 0 ? index : 0; } #moveSelection(delta: number): void { const rows = this.#listRows(); if (rows.length === 0) return; this.#selectedIndex = (this.#selectedIndex + delta + rows.length) % rows.length; this.#previewOffset = 0; this.#previewCache = undefined; this.#flash = undefined; } #setSelection(index: number): void { const rows = this.#listRows(); if (rows.length === 0) return; this.#selectedIndex = Math.max(0, Math.min(index, rows.length - 1)); this.#previewOffset = 0; this.#previewCache = undefined; this.#flash = undefined; } #moveFocus(delta: number, wide: boolean): void { const panes: PanelFocus[] = ["list", "preview"]; const current = wide ? this.#focus : this.#narrowView; const currentIndex = panes.indexOf(current); const next = panes[(currentIndex + delta + panes.length) % panes.length] ?? "list"; this.#focus = next; this.#narrowView = next; this.#flash = undefined; } #setCurrentGroupCollapsed(collapsed: boolean): void { const group = this.#currentGroup(); if (!group) return; if (this.#query.trim()) { this.#flash = { kind: collapsed ? "warning" : "success", text: collapsed ? "Groups stay expanded while filtering" : `Already expanded ${group.scope}`, }; return; } const currentlyCollapsed = this.#collapsedGroupKeys.has(group.key); if (currentlyCollapsed === collapsed) { this.#flash = { kind: "success", text: `Already ${collapsed ? "collapsed" : "expanded"} ${group.scope}`, }; return; } if (collapsed) this.#collapsedGroupKeys.add(group.key); else this.#collapsedGroupKeys.delete(group.key); const rows = this.#listRows(); const nextIndex = rows.findIndex((row) => this.#rowKey(row) === `group:${group.key}`); this.#selectedIndex = nextIndex >= 0 ? nextIndex : this.#firstItemRowIndex(rows); this.#previewOffset = 0; this.#previewCache = undefined; this.#flash = { kind: "success", text: `${collapsed ? "Collapsed" : "Expanded"} ${group.scope} · ${group.count} ${group.count === 1 ? "file" : "files"}`, }; } #toggleCurrentGroup(): void { const group = this.#currentGroup(); if (!group) return; this.#setCurrentGroupCollapsed(!group.collapsed); } #handleListInput(data: string, wide: boolean): void { const rows = this.#listRows(); if (this.#keybindings.matches(data, "tui.select.up") || data === "k") { this.#moveSelection(-1); } else if (this.#keybindings.matches(data, "tui.select.down") || data === "j") { this.#moveSelection(1); } else if (this.#keybindings.matches(data, "tui.select.pageUp")) { this.#setSelection(this.#selectedIndex - 8); } else if (this.#keybindings.matches(data, "tui.select.pageDown")) { this.#setSelection(this.#selectedIndex + 8); } else if (matchesKey(data, Key.home)) { this.#setSelection(0); } else if (matchesKey(data, Key.end)) { this.#setSelection(rows.length - 1); } else if (matchesKey(data, Key.shift("space"))) { this.#toggleCurrentItem(); } else if (data === " ") { if (this.#currentGroup()) this.#toggleCurrentGroup(); else this.#toggleCurrentItem(); } else if (data === "r") { this.#resetCurrentItem(); } else if (data === "u") { this.#undoLatestChange(); } else if (this.#keybindings.matches(data, "tui.select.confirm")) { if (this.#currentGroup()) { this.#toggleCurrentGroup(); } else if (!wide && this.#currentItem()) { this.#focus = "preview"; this.#narrowView = "preview"; this.#flash = undefined; } } } #handlePreviewInput(data: string, wide: boolean): void { const pageSize = Math.max(1, this.#lastPreviewViewportHeight - 1); if (this.#keybindings.matches(data, "tui.select.up") || data === "k") { this.#scrollPreview(-1); } else if (this.#keybindings.matches(data, "tui.select.down") || data === "j") { this.#scrollPreview(1); } else if (this.#keybindings.matches(data, "tui.select.pageUp") || matchesKey(data, Key.pageUp)) { this.#scrollPreview(-pageSize); } else if (this.#keybindings.matches(data, "tui.select.pageDown") || matchesKey(data, Key.pageDown)) { this.#scrollPreview(pageSize); } else if (matchesKey(data, Key.home)) { this.#previewOffset = 0; } else if (matchesKey(data, Key.end)) { this.#previewOffset = Math.max(0, this.#lastPreviewLineCount - this.#lastPreviewViewportHeight); } else if (!wide && this.#keybindings.matches(data, "tui.select.confirm")) { this.#narrowView = "list"; this.#focus = "list"; } } #scrollPreview(delta: number): void { const maximum = Math.max(0, this.#lastPreviewLineCount - this.#lastPreviewViewportHeight); this.#previewOffset = Math.max(0, Math.min(this.#previewOffset + delta, maximum)); this.#flash = undefined; } #pad(text: string, width: number): string { const clipped = truncateToWidth(text, Math.max(0, width), ""); return `${clipped}${" ".repeat(Math.max(0, width - visibleWidth(clipped)))}`; } #selectedBackground(content: string): string { return content .split("\x1b[0m") .map((segment) => this.#theme.bg("selectedBg", segment)) .join("\x1b[0m"); } #joined(left: string, right: string, width: number): string { const gap = 2; const rightWidth = visibleWidth(right); const leftWidth = Math.max(0, width - rightWidth - gap); const clippedLeft = truncateToWidth(left, leftWidth, "…"); return `${clippedLeft}${" ".repeat(Math.max(gap, width - visibleWidth(clippedLeft) - rightWidth))}${right}`; } #border(left: string, middle: string, right: string, innerWidth: number): string { return this.#theme.fg("borderMuted", `${left}${middle.repeat(Math.max(0, innerWidth))}${right}`); } #topBorder(width: number, titleText: string): string[] { const innerWidth = width - 2; const title = ` ${titleText} `; if (visibleWidth(title) + 3 > width) { return [ this.#border("╭", "─", "╮", innerWidth), this.#fullLine(this.#theme.fg("accent", this.#theme.bold(titleText)), innerWidth), ]; } const fill = Math.max(0, innerWidth - visibleWidth(title) - 1); return [ `${this.#theme.fg("borderMuted", "╭─")}${this.#theme.fg("accent", this.#theme.bold(title))}${this.#theme.fg( "borderMuted", `${"─".repeat(fill)}╮`, )}`, ]; } #fullLine(content: string, innerWidth: number, selected = false): string { const padded = this.#pad(` ${content}`, innerWidth); const body = selected ? this.#selectedBackground(padded) : padded; return `${this.#theme.fg("borderMuted", "│")}${body}${this.#theme.fg("borderMuted", "│")}`; } #paneContent(content: string, width: number, selected = false): string { const padded = this.#pad(` ${content}`, width); return selected ? this.#selectedBackground(padded) : padded; } #maximumOverlayHeight(): number { return Math.max(1, Math.floor(this.#tui.terminal.rows * 0.9)); } #preferredOverlayHeight(): number { return Math.max(1, Math.min(this.#maximumOverlayHeight(), Math.floor(this.#tui.terminal.rows * 0.78))); } #summary(): string { const includedCount = this.#items.filter((item) => !this.#disabledPaths.has(item.path)).length; const excludedCount = this.#items.length - includedCount; const parts = [ this.#theme.fg("accent", `● ${includedCount} Included`), this.#theme.fg(excludedCount > 0 ? "warning" : "dim", `○ ${excludedCount} Excluded`), ]; return parts.join(this.#theme.fg("dim", " · ")); } #searchValue(width: number): string { if (this.#filterEditing) { const inputWidth = this.#labeledValueWidth(width); const rendered = this.#filterInput.render(inputWidth + 2)[0] ?? "> "; return (rendered.startsWith("> ") ? rendered.slice(2) : rendered).trimEnd(); } if (this.#query) return this.#theme.fg("text", this.#query); const placeholder = width >= 42 ? " to filter paths" : " filter"; return `${this.#theme.fg("accent", "/")}${this.#theme.fg("dim", placeholder)}`; } #sectionSegment(label: string, width: number, focused: boolean): string { if (width <= 0) return ""; const prefix = "─ "; const suffix = " "; const titleWidth = visibleWidth(prefix) + visibleWidth(label) + visibleWidth(suffix); const styledLabel = focused ? this.#theme.fg("accent", this.#theme.bold(label)) : this.#theme.fg("muted", label); const fill = Math.max(0, width - titleWidth); return `${this.#theme.fg("borderMuted", prefix)}${styledLabel}${this.#theme.fg( "borderMuted", `${suffix}${"─".repeat(fill)}`, )}`; } #visibleListRows(height: number): ListRow[] { const rows = this.#listRows(); if (rows.length <= height) return rows; const selectedRow = Math.max(0, Math.min(this.#selectedIndex, rows.length - 1)); const start = Math.max(0, Math.min(selectedRow - Math.floor(height / 2), rows.length - height)); const visible = rows.slice(start, start + height); if (start > 0 && visible[0]?.type === "item") { const availableRows = height - 1; const stickyStart = Math.max( 0, Math.min(selectedRow - Math.floor(availableRows / 2), rows.length - availableRows), ); const stickyRows = rows.slice(stickyStart, stickyStart + availableRows); const firstRow = stickyRows[0]; if (firstRow?.type === "item") { let groupIndex = stickyStart - 1; while (groupIndex >= 0 && rows[groupIndex]?.type !== "group") groupIndex -= 1; const group = rows[groupIndex]; if (group?.type === "group") return [group, ...stickyRows]; } return rows.slice(stickyStart, stickyStart + height); } return visible; } #renderListRows(width: number, height: number, focused: boolean): string[] { const items = this.#filteredItems(); if (items.length === 0) { const message = this.#items.length === 0 ? "No Context instruction files discovered." : `No paths match “${this.#query}”.`; return [ this.#paneContent(this.#theme.fg("muted", message), width), ...Array.from({ length: height - 1 }, () => " ".repeat(width)), ]; } const rows = this.#visibleListRows(height); const selectedRow = this.#selectedRow(); const selectedKey = selectedRow ? this.#rowKey(selectedRow) : undefined; const rendered = rows.map((row) => { if (row.type === "group") { const selected = this.#rowKey(row) === selectedKey; const icon = row.collapsed ? "▸" : "▾"; const label = selected ? this.#theme.fg("accent", this.#theme.bold(row.scope)) : this.#theme.fg("muted", row.scope); return this.#paneContent( `${this.#theme.fg("muted", icon)} ${label} ${this.#theme.fg("muted", `(${row.count})`)}`, width, selected && focused, ); } const selected = this.#rowKey(row) === selectedKey; const disabled = this.#disabledPaths.has(row.item.path); const status = this.#rowStatus(row.item); const icon = disabled ? this.#theme.fg("dim", "○") : this.#theme.fg("accent", "●"); const label = selected ? this.#theme.fg("accent", this.#theme.bold(row.item.label)) : disabled ? this.#theme.fg("dim", row.item.label) : this.#theme.fg("text", row.item.label); const badge = this.#theme.fg( status === "Pending" ? "warning" : status === "Excluded" ? "muted" : "dim", status, ); const content = this.#joined(` ${icon} ${label}`, badge, Math.max(0, width - 2)); return this.#paneContent(content, width, selected && focused); }); while (rendered.length < height) rendered.push(" ".repeat(width)); return rendered.slice(0, height); } #previewLines(item: ContextListItem, width: number): string[] { const contentWidth = Math.max(1, width); if (this.#previewCache?.path === item.path && this.#previewCache.width === contentWidth) { return this.#previewCache.lines; } if (item.content.length === 0) { this.#previewCache = { path: item.path, width: contentWidth, lines: [] }; return []; } const lines: string[] = []; let inCodeFence = false; for (const sourceLine of item.content.replace(/\r\n/g, "\n").split("\n")) { const expanded = sourceLine.replace(/\t/g, " "); const isFence = /^\s*```/.test(expanded); let styled = expanded; if (inCodeFence || isFence) styled = this.#theme.fg("mdCodeBlock", expanded); else if (/^#{1,6}\s/.test(expanded)) styled = this.#theme.fg("mdHeading", this.#theme.bold(expanded)); else if (/^\s*>/.test(expanded)) styled = this.#theme.fg("mdQuote", expanded); else styled = this.#theme.fg("text", expanded); const wrapped = expanded.length === 0 ? [""] : wrapTextWithAnsi(styled, contentWidth); lines.push(...wrapped); if (isFence) inCodeFence = !inCodeFence; } this.#previewCache = { path: item.path, width: contentWidth, lines }; return lines; } #labelWidth(width: number): number { return width >= 30 ? Math.min(10, width - 1) : 0; } #labeledValueWidth(width: number): number { const contentWidth = Math.max(1, width); const labelWidth = this.#labelWidth(contentWidth); if (labelWidth > 0) return Math.max(1, contentWidth - labelWidth); const indent = Math.min(2, Math.max(0, contentWidth - 1)); return Math.max(1, contentWidth - indent); } #labelPrefix(label: string, width: number): string { if (width <= 0) return ""; const gap = Math.min(2, width); const textWidth = Math.max(0, width - gap); const clipped = truncateToWidth(label, textWidth, ""); const padded = `${clipped}${" ".repeat(Math.max(0, textWidth - visibleWidth(clipped)))}`; return `${this.#theme.fg("muted", padded)}${" ".repeat(gap)}`; } #labeledTextLines(label: string, value: string, width: number): string[] { const contentWidth = Math.max(1, width); const labelWidth = this.#labelWidth(contentWidth); if (labelWidth === 0) { const indent = Math.min(2, Math.max(0, contentWidth - 1)); const valueWidth = Math.max(1, contentWidth - indent); return [ ...wrapTextWithAnsi(this.#theme.fg("muted", label), contentWidth), ...wrapTextWithAnsi(value, valueWidth).map((line) => `${" ".repeat(indent)}${line}`), ]; } const valueWidth = Math.max(1, contentWidth - labelWidth); const valueLines = wrapTextWithAnsi(value, valueWidth); return (valueLines.length > 0 ? valueLines : [""]).map( (line, index) => `${this.#labelPrefix(index === 0 ? label : "", labelWidth)}${line}`, ); } #stateSummary(item: ContextListItem): string { const state = this.#stateLabel(item); const source = this.#sourceStatus(item); const sourceColor = this.#isPathPending(item.path) ? "warning" : this.#disabledPaths.has(item.path) ? "muted" : "dim"; return `${this.#theme.fg("muted", `Current ${state} · Default Included · `)}${this.#theme.fg(sourceColor, source)}`; } #itemDetailLines(item: ContextListItem, width: number): string[] { const bytes = new TextEncoder().encode(item.content).length; const metadata = `${lineCount(item.content)} lines · ${formatBytes(bytes)} · ~${formatCount(estimateTokens(item.content))} tokens`; return [ ...this.#labeledTextLines("Path", this.#theme.fg("text", item.label), width), ...this.#labeledTextLines("Scope", this.#theme.fg("muted", item.scope), width), ...this.#labeledTextLines("State", this.#stateSummary(item), width), ...this.#labeledTextLines("Size", this.#theme.fg("dim", metadata), width), ]; } #groupAction(group: ListGroupRow): string { return group.collapsed ? "l to expand this group · Space/Enter to toggle." : "h to collapse this group · Space/Enter to toggle."; } #groupDetailLines(group: ListGroupRow, width: number): string[] { const state = group.collapsed ? "Collapsed" : "Expanded"; return [ ...this.#labeledTextLines("Group", this.#theme.fg("text", group.scope), width), ...this.#labeledTextLines("Files", this.#theme.fg("muted", String(group.count)), width), ...this.#labeledTextLines("State", this.#theme.fg("accent", state), width), ...wrapTextWithAnsi(this.#theme.fg("dim", this.#groupAction(group)), width), ]; } #renderGroupPreviewRows(group: ListGroupRow, width: number, height: number): string[] { const lines = this.#groupDetailLines(group, Math.max(1, width - 2)); this.#lastPreviewLineCount = lines.length; this.#lastPreviewViewportHeight = height; this.#previewOffset = Math.max(0, Math.min(this.#previewOffset, Math.max(0, lines.length - height))); const rows = lines .slice(this.#previewOffset, this.#previewOffset + height) .map((line) => this.#paneContent(line, width)); while (rows.length < height) rows.push(" ".repeat(width)); return rows; } #renderPreviewRows(width: number, height: number): string[] { const group = this.#currentGroup(); if (group) return this.#renderGroupPreviewRows(group, width, height); const item = this.#currentItem(); if (!item) { const message = this.#items.length === 0 ? "No Context content to preview." : "Press / to change the filter."; return [ this.#paneContent(this.#theme.fg("muted", message), width), ...Array.from({ length: height - 1 }, () => " ".repeat(width)), ]; } const detailLines = this.#itemDetailLines(item, Math.max(1, width - 2)); const details = detailLines.map((line) => this.#paneContent(line, width)); const previewHeight = Math.max(1, height - details.length - 1); const contentWidth = Math.max(1, width - 2); const previewLines = this.#previewLines(item, contentWidth); this.#lastPreviewLineCount = previewLines.length; this.#lastPreviewViewportHeight = previewHeight; this.#previewOffset = Math.max( 0, Math.min(this.#previewOffset, Math.max(0, previewLines.length - previewHeight)), ); const position = previewLines.length === 0 ? "" : ` · View ${this.#previewOffset + 1}–${Math.min(this.#previewOffset + previewHeight, previewLines.length)} of ${previewLines.length}`; const dividerWidth = Math.max(0, width - 2); const dividerLabel = truncateToWidth( `${this.#theme.fg("borderMuted", "─ ")}${this.#theme.fg("accent", this.#theme.bold("Content"))}${this.#theme.fg("dim", position)}${this.#theme.fg("borderMuted", " ")}`, dividerWidth, "…", ); const separator = this.#paneContent( `${dividerLabel}${this.#theme.fg("borderMuted", "─".repeat(Math.max(0, dividerWidth - visibleWidth(dividerLabel))))}`, width, ); const content = previewLines.length === 0 ? [this.#paneContent(this.#theme.fg("warning", "This file is empty."), width)] : previewLines .slice(this.#previewOffset, this.#previewOffset + previewHeight) .map((line) => this.#paneContent(line, width)); const rows = [...details, separator, ...content]; while (rows.length < height) rows.push(" ".repeat(width)); return rows.slice(0, height); } #helpWithFlash(help: string, width: number): string { const styledHelp = this.#theme.fg("dim", help); if (!this.#flash) return styledHelp; const flashColor = this.#flash.kind === "error" ? "error" : this.#flash.kind === "warning" ? "warning" : "success"; const styledFlash = this.#theme.fg(flashColor, this.#flash.text); if (this.#flash.kind === "error") return styledFlash; return this.#joined(styledHelp, styledFlash, width); } #wideListHelp(width: number): string { if (this.#currentGroup()) { return width >= 112 ? "j/k h collapse l expand Space/Enter toggle Tab preview / filter ? guide Ctrl+S apply" : "j/k h collapse l expand Space/Enter toggle / filter"; } return width >= 112 ? "j/k select h/l focus / filter Space toggle r included u undo ? guide Ctrl+S apply" : "j/k select h/l focus / filter Space toggle r/u"; } #narrowListHelp(width: number): string { if (this.#currentGroup()) { return width >= 68 ? "j/k h collapse l expand Space/Enter toggle / filter" : "j/k h fold l open Space/Enter"; } return width >= 68 ? "j/k Enter preview h/l focus / filter Space r/u" : width >= 50 ? "j/k Enter preview h/l / filter" : "j/k Enter preview h/l focus"; } #renderWide(width: number): string[] { const innerWidth = width - 2; const headerWidth = Math.max(1, innerWidth - 2); const listWidth = Math.min(44, Math.max(32, Math.floor(innerWidth * 0.38))); const previewWidth = innerWidth - listWidth - 1; const lines = this.#topBorder(width, "Context"); for (const summaryLine of this.#labeledTextLines("State", this.#summary(), headerWidth)) { lines.push(this.#fullLine(summaryLine, innerWidth)); } for (const searchLine of this.#labeledTextLines("Filter", this.#searchValue(headerWidth), headerWidth)) { lines.push(this.#fullLine(searchLine, innerWidth)); } lines.push( `${this.#theme.fg("borderMuted", "├")}${this.#sectionSegment("Files", listWidth, this.#focus === "list")}${this.#theme.fg( "borderMuted", "┬", )}${this.#sectionSegment("Preview", previewWidth, this.#focus === "preview")}${this.#theme.fg("borderMuted", "┤")}`, ); const contentHeight = Math.max(4, Math.min(30, this.#preferredOverlayHeight() - lines.length - 2)); const listRows = this.#renderListRows(listWidth, contentHeight, this.#focus === "list"); const previewRows = this.#renderPreviewRows(previewWidth, contentHeight); for (let index = 0; index < contentHeight; index++) { lines.push( `${this.#theme.fg("borderMuted", "│")}${listRows[index]}${this.#theme.fg("borderMuted", "│")}${previewRows[index]}${this.#theme.fg( "borderMuted", "│", )}`, ); } lines.push( `${this.#theme.fg("borderMuted", "├")}${this.#theme.fg("borderMuted", "─".repeat(listWidth))}${this.#theme.fg( "borderMuted", "┴", )}${this.#theme.fg("borderMuted", "─".repeat(previewWidth))}${this.#theme.fg("borderMuted", "┤")}`, ); const help = this.#filterEditing ? "Type filter ←/→ cursor ↑/↓ select Ctrl+U clear Ctrl+W word Enter keep Esc cancel" : this.#focus === "list" ? this.#wideListHelp(width) : width >= 100 ? "j/k/PgUp/PgDn scroll h/l focus / filter ? guide Ctrl+S apply Esc close" : "j/k/Pg scroll h/l focus / filter Ctrl+S apply Esc close"; lines.push(this.#fullLine(this.#helpWithFlash(help, Math.max(0, innerWidth - 2)), innerWidth)); lines.push(this.#border("╰", "─", "╯", innerWidth)); return lines; } #renderNarrow(width: number): string[] { return this.#narrowView === "preview" ? this.#renderNarrowPreview(width) : this.#renderNarrowList(width); } #renderNarrowList(width: number): string[] { const innerWidth = width - 2; const headerWidth = Math.max(1, innerWidth - 2); const lines = this.#topBorder(width, "Context"); for (const summaryLine of this.#labeledTextLines("State", this.#summary(), headerWidth)) { lines.push(this.#fullLine(summaryLine, innerWidth)); } for (const searchLine of this.#labeledTextLines("Filter", this.#searchValue(headerWidth), headerWidth)) { lines.push(this.#fullLine(searchLine, innerWidth)); } lines.push( `${this.#theme.fg("borderMuted", "├")}${this.#sectionSegment("Files", innerWidth, true)}${this.#theme.fg("borderMuted", "┤")}`, ); const item = this.#currentItem(); const group = this.#currentGroup(); const detailWidth = Math.max(1, innerWidth - 2); const detailLines = item ? [ ...this.#labeledTextLines("Path", this.#theme.fg("text", item.label), detailWidth), ...this.#labeledTextLines("State", this.#stateSummary(item), detailWidth), ] : group ? this.#groupDetailLines(group, detailWidth) : [this.#theme.fg("dim", "Press / to change the filter."), ""]; const contentHeight = Math.max( 3, Math.min(16, this.#preferredOverlayHeight() - lines.length - detailLines.length - 4), ); const listRows = this.#renderListRows(innerWidth, contentHeight, true); for (const row of listRows) lines.push(`${this.#theme.fg("borderMuted", "│")}${row}${this.#theme.fg("borderMuted", "│")}`); lines.push(this.#border("├", "─", "┤", innerWidth)); for (const detailLine of detailLines) lines.push(this.#fullLine(detailLine, innerWidth)); lines.push(this.#border("├", "─", "┤", innerWidth)); const help = this.#filterEditing ? width >= 72 ? "Type filter ←/→ cursor ↑/↓ select Ctrl+U clear Ctrl+W word Enter keep Esc cancel" : width >= 52 ? "Type filter Ctrl+U clear Ctrl+W word Enter keep Esc cancel" : "Type filter Enter keep Esc cancel" : this.#narrowListHelp(width); lines.push(this.#fullLine(this.#helpWithFlash(help, Math.max(0, innerWidth - 2)), innerWidth)); lines.push(this.#border("╰", "─", "╯", innerWidth)); return lines; } #renderNarrowPreview(width: number): string[] { const innerWidth = width - 2; const lines = this.#topBorder(width, "Context preview"); const contentHeight = Math.max(5, Math.min(24, this.#preferredOverlayHeight() - lines.length - 4)); lines.push( `${this.#theme.fg("borderMuted", "├")}${this.#sectionSegment("Full file", innerWidth, true)}${this.#theme.fg("borderMuted", "┤")}`, ); const previewRows = this.#renderPreviewRows(innerWidth, contentHeight); for (const row of previewRows) lines.push(`${this.#theme.fg("borderMuted", "│")}${row}${this.#theme.fg("borderMuted", "│")}`); lines.push(this.#border("├", "─", "┤", innerWidth)); const help = width >= 68 ? "j/k/Pg scroll h/l focus / filter Ctrl+S apply Esc back" : width >= 48 ? "j/k scroll h/l focus / filter Esc back" : "j/k scroll h/l focus Esc back"; lines.push(this.#fullLine(this.#helpWithFlash(help, Math.max(0, innerWidth - 2)), innerWidth)); lines.push(this.#border("╰", "─", "╯", innerWidth)); return lines; } } export default function contextControlExtension(pi: ExtensionAPI) { const configPath = join(getAgentDir(), CONFIG_FILE_NAME); const disabledPaths = new Set(); let configError: string | undefined; let promptMismatchWarningShown = false; const loadConfiguration = () => { const loadedConfig = readConfig(configPath); replacePaths(disabledPaths, loadedConfig.disabledPaths); configError = loadedConfig.error; }; loadConfiguration(); pi.on("session_start", (_event, ctx) => { loadConfiguration(); if (configError) ctx.ui.notify(configError, "error"); }); pi.registerCommand("context", { description: "Enable or disable loaded Context instruction files", handler: async (args, ctx) => { if (args.trim() !== "") { ctx.ui.notify("Usage: /context", "error"); return; } if (ctx.mode !== "tui") { ctx.ui.notify("/context requires TUI mode", "error"); return; } if (configError) { ctx.ui.notify(configError, "error"); return; } const contextFiles = ctx.getSystemPromptOptions().contextFiles ?? []; const cwd = canonicalPath(ctx.cwd); const agentDir = canonicalPath(getAgentDir()); const items: ContextListItem[] = contextFiles.map((file) => { const path = canonicalPath(file.path, ctx.cwd); const parentDirectory = dirname(path); const scope: ContextScope = parentDirectory === agentDir ? "User" : parentDirectory === cwd ? "Current project" : "Inherited"; return { path, label: displayPath(path), scope, content: file.content }; }); await ctx.ui.custom( (tui, theme, keybindings, done) => new ContextControlPanel({ tui, theme, keybindings, items, disabledPaths, onApply: (nextDisabledPaths) => { try { if (!pathsEqual(disabledPaths, nextDisabledPaths)) { writeConfig(configPath, nextDisabledPaths); } replacePaths(disabledPaths, nextDisabledPaths); configError = undefined; } catch { configError = `Could not write context control config: ${configPath}`; throw new Error(configError); } }, onDone: done, }), { overlay: true, overlayOptions: { width: 120, minWidth: 36, maxHeight: "90%", anchor: "center", margin: 1, }, }, ); }, }); pi.on("before_agent_start", (event, ctx) => { if (disabledPaths.size === 0) return; const contextFiles = event.systemPromptOptions.contextFiles ?? []; const filteredPrompt = filterProjectContext(event.systemPrompt, contextFiles, disabledPaths, ctx.cwd); const hasDisabledLoadedFile = contextFiles.some((file) => disabledPaths.has(canonicalPath(file.path, ctx.cwd))); if (filteredPrompt === event.systemPrompt) { if (hasDisabledLoadedFile && !promptMismatchWarningShown) { promptMismatchWarningShown = true; ctx.ui.notify("Context control could not locate Pi's project context section.", "warning"); } return; } promptMismatchWarningShown = false; return { systemPrompt: filteredPrompt }; }); }