From c53b22db43666b0b15d0ab6052d9118d3a20aec1 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 25 Dec 2025 15:35:06 +0100 Subject: [PATCH 1/2] Add /settings command with unified settings menu - Add SettingsList component to tui package with support for: - Inline value cycling (Enter/Space toggles) - Submenus for complex selections - Selection preservation when returning from submenu - Add /settings slash command consolidating: - Auto-compact (toggle) - Show images (toggle) - Queue mode (cycle) - Hide thinking (toggle) - Collapse changelog (toggle) - Thinking level (submenu) - Theme (submenu with preview) - Update AGENTS.md to clarify no inline imports rule Fixes #310 --- AGENTS.md | 2 +- .../components/settings-selector.ts | 251 ++++++++++++++++++ .../src/modes/interactive/interactive-mode.ts | 83 +++++- .../src/modes/interactive/theme/theme.ts | 10 + packages/tui/src/components/settings-list.ts | 188 +++++++++++++ packages/tui/src/index.ts | 1 + 6 files changed, 533 insertions(+), 2 deletions(-) create mode 100644 packages/coding-agent/src/modes/interactive/components/settings-selector.ts create mode 100644 packages/tui/src/components/settings-list.ts diff --git a/AGENTS.md b/AGENTS.md index 780d382f..3990816f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ read README.md, then ask which module(s) to work on. Based on the answer, read t ## Code Quality - No `any` types unless absolutely necessary - Check node_modules for external API type definitions instead of guessing -- No inline imports like `await import("./foo.js")` +- **NEVER use inline imports** - no `await import("./foo.js")`, no `import("pkg").Type` in type positions, no dynamic imports for types. Always use standard top-level imports. - NEVER remove or downgrade code to fix type errors from outdated dependencies; upgrade the dependency instead - Always ask before removing functionality or code that appears to be intentional diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts new file mode 100644 index 00000000..1202e3ee --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -0,0 +1,251 @@ +import type { ThinkingLevel } from "@mariozechner/pi-agent-core"; +import { + Container, + getCapabilities, + type SelectItem, + SelectList, + type SettingItem, + SettingsList, + Spacer, + Text, +} from "@mariozechner/pi-tui"; +import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.js"; +import { DynamicBorder } from "./dynamic-border.js"; + +const THINKING_DESCRIPTIONS: Record = { + off: "No reasoning", + minimal: "Very brief reasoning (~1k tokens)", + low: "Light reasoning (~2k tokens)", + medium: "Moderate reasoning (~8k tokens)", + high: "Deep reasoning (~16k tokens)", + xhigh: "Maximum reasoning (~32k tokens)", +}; + +export interface SettingsConfig { + autoCompact: boolean; + showImages: boolean; + queueMode: "all" | "one-at-a-time"; + thinkingLevel: ThinkingLevel; + availableThinkingLevels: ThinkingLevel[]; + currentTheme: string; + availableThemes: string[]; + hideThinkingBlock: boolean; + collapseChangelog: boolean; +} + +export interface SettingsCallbacks { + onAutoCompactChange: (enabled: boolean) => void; + onShowImagesChange: (enabled: boolean) => void; + onQueueModeChange: (mode: "all" | "one-at-a-time") => void; + onThinkingLevelChange: (level: ThinkingLevel) => void; + onThemeChange: (theme: string) => void; + onThemePreview?: (theme: string) => void; + onHideThinkingBlockChange: (hidden: boolean) => void; + onCollapseChangelogChange: (collapsed: boolean) => void; + onCancel: () => void; +} + +/** + * A submenu component for selecting from a list of options. + */ +class SelectSubmenu extends Container { + private selectList: SelectList; + + constructor( + title: string, + description: string, + options: SelectItem[], + currentValue: string, + onSelect: (value: string) => void, + onCancel: () => void, + onSelectionChange?: (value: string) => void, + ) { + super(); + + // Title + this.addChild(new Text(theme.bold(theme.fg("accent", title)), 0, 0)); + + // Description + if (description) { + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("muted", description), 0, 0)); + } + + // Spacer + this.addChild(new Spacer(1)); + + // Select list + this.selectList = new SelectList(options, Math.min(options.length, 10), getSelectListTheme()); + + // Pre-select current value + const currentIndex = options.findIndex((o) => o.value === currentValue); + if (currentIndex !== -1) { + this.selectList.setSelectedIndex(currentIndex); + } + + this.selectList.onSelect = (item) => { + onSelect(item.value); + }; + + this.selectList.onCancel = onCancel; + + if (onSelectionChange) { + this.selectList.onSelectionChange = (item) => { + onSelectionChange(item.value); + }; + } + + this.addChild(this.selectList); + + // Hint + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("dim", " Enter to select · Esc to go back"), 0, 0)); + } + + handleInput(data: string): void { + this.selectList.handleInput(data); + } +} + +/** + * Main settings selector component. + */ +export class SettingsSelectorComponent extends Container { + private settingsList: SettingsList; + + constructor(config: SettingsConfig, callbacks: SettingsCallbacks) { + super(); + + const supportsImages = getCapabilities().images; + + const items: SettingItem[] = [ + { + id: "autocompact", + label: "Auto-compact", + description: "Automatically compact context when it gets too large", + currentValue: config.autoCompact ? "true" : "false", + values: ["true", "false"], + }, + { + id: "queue-mode", + label: "Queue mode", + description: "How to process queued messages while agent is working", + currentValue: config.queueMode, + values: ["one-at-a-time", "all"], + }, + { + id: "hide-thinking", + label: "Hide thinking", + description: "Hide thinking blocks in assistant responses", + currentValue: config.hideThinkingBlock ? "true" : "false", + values: ["true", "false"], + }, + { + id: "collapse-changelog", + label: "Collapse changelog", + description: "Show condensed changelog after updates", + currentValue: config.collapseChangelog ? "true" : "false", + values: ["true", "false"], + }, + { + id: "thinking", + label: "Thinking level", + description: "Reasoning depth for thinking-capable models", + currentValue: config.thinkingLevel, + submenu: (currentValue, done) => + new SelectSubmenu( + "Thinking Level", + "Select reasoning depth for thinking-capable models", + config.availableThinkingLevels.map((level) => ({ + value: level, + label: level, + description: THINKING_DESCRIPTIONS[level], + })), + currentValue, + (value) => { + callbacks.onThinkingLevelChange(value as ThinkingLevel); + done(value); + }, + () => done(), + ), + }, + { + id: "theme", + label: "Theme", + description: "Color theme for the interface", + currentValue: config.currentTheme, + submenu: (currentValue, done) => + new SelectSubmenu( + "Theme", + "Select color theme", + config.availableThemes.map((t) => ({ + value: t, + label: t, + })), + currentValue, + (value) => { + callbacks.onThemeChange(value); + done(value); + }, + () => { + // Restore original theme on cancel + callbacks.onThemePreview?.(currentValue); + done(); + }, + (value) => { + // Preview theme on selection change + callbacks.onThemePreview?.(value); + }, + ), + }, + ]; + + // Only show image toggle if terminal supports it + if (supportsImages) { + // Insert after autocompact + items.splice(1, 0, { + id: "show-images", + label: "Show images", + description: "Render images inline in terminal", + currentValue: config.showImages ? "true" : "false", + values: ["true", "false"], + }); + } + + // Add borders + this.addChild(new DynamicBorder()); + + this.settingsList = new SettingsList( + items, + 10, + getSettingsListTheme(), + (id, newValue) => { + switch (id) { + case "autocompact": + callbacks.onAutoCompactChange(newValue === "true"); + break; + case "show-images": + callbacks.onShowImagesChange(newValue === "true"); + break; + case "queue-mode": + callbacks.onQueueModeChange(newValue as "all" | "one-at-a-time"); + break; + case "hide-thinking": + callbacks.onHideThinkingBlockChange(newValue === "true"); + break; + case "collapse-changelog": + callbacks.onCollapseChangelogChange(newValue === "true"); + break; + } + }, + callbacks.onCancel, + ); + + this.addChild(this.settingsList); + this.addChild(new DynamicBorder()); + } + + getSettingsList(): SettingsList { + return this.settingsList; + } +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 228148d5..aa6de822 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -54,13 +54,14 @@ import { ModelSelectorComponent } from "./components/model-selector.js"; import { OAuthSelectorComponent } from "./components/oauth-selector.js"; import { QueueModeSelectorComponent } from "./components/queue-mode-selector.js"; import { SessionSelectorComponent } from "./components/session-selector.js"; +import { SettingsSelectorComponent } from "./components/settings-selector.js"; import { ShowImagesSelectorComponent } from "./components/show-images-selector.js"; import { ThemeSelectorComponent } from "./components/theme-selector.js"; import { ThinkingSelectorComponent } from "./components/thinking-selector.js"; import { ToolExecutionComponent } from "./components/tool-execution.js"; import { UserMessageComponent } from "./components/user-message.js"; import { UserMessageSelectorComponent } from "./components/user-message-selector.js"; -import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "./theme/theme.js"; +import { getAvailableThemes, getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "./theme/theme.js"; export class InteractiveMode { private session: AgentSession; @@ -157,6 +158,7 @@ export class InteractiveMode { // Define slash commands for autocomplete const slashCommands: SlashCommand[] = [ + { name: "settings", description: "Open settings menu" }, { name: "thinking", description: "Select reasoning level (opens selector UI)" }, { name: "model", description: "Select model (opens selector UI)" }, { name: "export", description: "Export session to HTML file" }, @@ -612,6 +614,11 @@ export class InteractiveMode { if (!text) return; // Handle slash commands + if (text === "/settings") { + this.showSettingsSelector(); + this.editor.setText(""); + return; + } if (text === "/thinking") { this.showThinkingSelector(); this.editor.setText(""); @@ -1405,6 +1412,80 @@ export class InteractiveMode { this.ui.requestRender(); } + private showSettingsSelector(): void { + this.showSelector((done) => { + const selector = new SettingsSelectorComponent( + { + autoCompact: this.session.autoCompactionEnabled, + showImages: this.settingsManager.getShowImages(), + queueMode: this.session.queueMode, + thinkingLevel: this.session.thinkingLevel, + availableThinkingLevels: this.session.getAvailableThinkingLevels(), + currentTheme: this.settingsManager.getTheme() || "dark", + availableThemes: getAvailableThemes(), + hideThinkingBlock: this.hideThinkingBlock, + collapseChangelog: this.settingsManager.getCollapseChangelog(), + }, + { + onAutoCompactChange: (enabled) => { + this.session.setAutoCompactionEnabled(enabled); + this.footer.setAutoCompactEnabled(enabled); + }, + onShowImagesChange: (enabled) => { + this.settingsManager.setShowImages(enabled); + for (const child of this.chatContainer.children) { + if (child instanceof ToolExecutionComponent) { + child.setShowImages(enabled); + } + } + }, + onQueueModeChange: (mode) => { + this.session.setQueueMode(mode); + }, + onThinkingLevelChange: (level) => { + this.session.setThinkingLevel(level); + this.footer.updateState(this.session.state); + this.updateEditorBorderColor(); + }, + onThemeChange: (themeName) => { + const result = setTheme(themeName, true); + this.settingsManager.setTheme(themeName); + this.ui.invalidate(); + if (!result.success) { + this.showError(`Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`); + } + }, + onThemePreview: (themeName) => { + const result = setTheme(themeName, true); + if (result.success) { + this.ui.invalidate(); + this.ui.requestRender(); + } + }, + onHideThinkingBlockChange: (hidden) => { + this.hideThinkingBlock = hidden; + this.settingsManager.setHideThinkingBlock(hidden); + for (const child of this.chatContainer.children) { + if (child instanceof AssistantMessageComponent) { + child.setHideThinkingBlock(hidden); + } + } + this.chatContainer.clear(); + this.rebuildChatFromMessages(); + }, + onCollapseChangelogChange: (collapsed) => { + this.settingsManager.setCollapseChangelog(collapsed); + }, + onCancel: () => { + done(); + this.ui.requestRender(); + }, + }, + ); + return { component: selector, focus: selector.getSettingsList() }; + }); + } + private showThinkingSelector(): void { this.showSelector((done) => { const selector = new ThinkingSelectorComponent( diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index ad8d3fb1..8f56b8e6 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -807,3 +807,13 @@ export function getEditorTheme(): EditorTheme { selectList: getSelectListTheme(), }; } + +export function getSettingsListTheme(): import("@mariozechner/pi-tui").SettingsListTheme { + return { + label: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : text), + value: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : theme.fg("muted", text)), + description: (text: string) => theme.fg("dim", text), + cursor: theme.fg("accent", "→ "), + hint: (text: string) => theme.fg("dim", text), + }; +} diff --git a/packages/tui/src/components/settings-list.ts b/packages/tui/src/components/settings-list.ts new file mode 100644 index 00000000..a96bd0eb --- /dev/null +++ b/packages/tui/src/components/settings-list.ts @@ -0,0 +1,188 @@ +import { isArrowDown, isArrowUp, isCtrlC, isEnter, isEscape } from "../keys.js"; +import type { Component } from "../tui.js"; +import { truncateToWidth, visibleWidth } from "../utils.js"; + +export interface SettingItem { + /** Unique identifier for this setting */ + id: string; + /** Display label (left side) */ + label: string; + /** Optional description shown when selected */ + description?: string; + /** Current value to display (right side) */ + currentValue: string; + /** If provided, Enter/Space cycles through these values */ + values?: string[]; + /** If provided, Enter opens this submenu. Receives current value and done callback. */ + submenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component; +} + +export interface SettingsListTheme { + label: (text: string, selected: boolean) => string; + value: (text: string, selected: boolean) => string; + description: (text: string) => string; + cursor: string; + hint: (text: string) => string; +} + +export class SettingsList implements Component { + private items: SettingItem[]; + private theme: SettingsListTheme; + private selectedIndex = 0; + private maxVisible: number; + private onChange: (id: string, newValue: string) => void; + private onCancel: () => void; + + // Submenu state + private submenuComponent: Component | null = null; + private submenuItemIndex: number | null = null; + + constructor( + items: SettingItem[], + maxVisible: number, + theme: SettingsListTheme, + onChange: (id: string, newValue: string) => void, + onCancel: () => void, + ) { + this.items = items; + this.maxVisible = maxVisible; + this.theme = theme; + this.onChange = onChange; + this.onCancel = onCancel; + } + + /** Update an item's currentValue */ + updateValue(id: string, newValue: string): void { + const item = this.items.find((i) => i.id === id); + if (item) { + item.currentValue = newValue; + } + } + + invalidate(): void { + this.submenuComponent?.invalidate?.(); + } + + render(width: number): string[] { + // If submenu is active, render it instead + if (this.submenuComponent) { + return this.submenuComponent.render(width); + } + + return this.renderMainList(width); + } + + private renderMainList(width: number): string[] { + const lines: string[] = []; + + if (this.items.length === 0) { + lines.push(this.theme.hint(" No settings available")); + return lines; + } + + // Calculate visible range with scrolling + const startIndex = Math.max( + 0, + Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.items.length - this.maxVisible), + ); + const endIndex = Math.min(startIndex + this.maxVisible, this.items.length); + + // Calculate max label width for alignment + const maxLabelWidth = Math.min(30, Math.max(...this.items.map((item) => visibleWidth(item.label)))); + + // Render visible items + for (let i = startIndex; i < endIndex; i++) { + const item = this.items[i]; + if (!item) continue; + + const isSelected = i === this.selectedIndex; + const prefix = isSelected ? this.theme.cursor : " "; + const prefixWidth = visibleWidth(prefix); + + // Pad label to align values + const labelPadded = item.label + " ".repeat(Math.max(0, maxLabelWidth - visibleWidth(item.label))); + const labelText = this.theme.label(labelPadded, isSelected); + + // Calculate space for value + const separator = " "; + const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator); + const valueMaxWidth = width - usedWidth - 2; + + const valueText = this.theme.value(truncateToWidth(item.currentValue, valueMaxWidth, ""), isSelected); + + lines.push(prefix + labelText + separator + valueText); + } + + // Add scroll indicator if needed + if (startIndex > 0 || endIndex < this.items.length) { + const scrollText = ` (${this.selectedIndex + 1}/${this.items.length})`; + lines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, ""))); + } + + // Add description for selected item + const selectedItem = this.items[this.selectedIndex]; + if (selectedItem?.description) { + lines.push(""); + lines.push(this.theme.description(` ${truncateToWidth(selectedItem.description, width - 4, "")}`)); + } + + // Add hint + lines.push(""); + lines.push(this.theme.hint(" Enter/Space to change · Esc to cancel")); + + return lines; + } + + handleInput(data: string): void { + // If submenu is active, delegate all input to it + // The submenu's onCancel (triggered by escape) will call done() which closes it + if (this.submenuComponent) { + this.submenuComponent.handleInput?.(data); + return; + } + + // Main list input handling + if (isArrowUp(data)) { + this.selectedIndex = this.selectedIndex === 0 ? this.items.length - 1 : this.selectedIndex - 1; + } else if (isArrowDown(data)) { + this.selectedIndex = this.selectedIndex === this.items.length - 1 ? 0 : this.selectedIndex + 1; + } else if (isEnter(data) || data === " ") { + this.activateItem(); + } else if (isEscape(data) || isCtrlC(data)) { + this.onCancel(); + } + } + + private activateItem(): void { + const item = this.items[this.selectedIndex]; + if (!item) return; + + if (item.submenu) { + // Open submenu, passing current value so it can pre-select correctly + this.submenuItemIndex = this.selectedIndex; + this.submenuComponent = item.submenu(item.currentValue, (selectedValue?: string) => { + if (selectedValue !== undefined) { + item.currentValue = selectedValue; + this.onChange(item.id, selectedValue); + } + this.closeSubmenu(); + }); + } else if (item.values && item.values.length > 0) { + // Cycle through values + const currentIndex = item.values.indexOf(item.currentValue); + const nextIndex = (currentIndex + 1) % item.values.length; + const newValue = item.values[nextIndex]; + item.currentValue = newValue; + this.onChange(item.id, newValue); + } + } + + private closeSubmenu(): void { + this.submenuComponent = null; + // Restore selection to the item that opened the submenu + if (this.submenuItemIndex !== null) { + this.selectedIndex = this.submenuItemIndex; + this.submenuItemIndex = null; + } + } +} diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 1e8bb605..62e52b13 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -15,6 +15,7 @@ export { Input } from "./components/input.js"; export { Loader } from "./components/loader.js"; export { type DefaultTextStyle, Markdown, type MarkdownTheme } from "./components/markdown.js"; export { type SelectItem, SelectList, type SelectListTheme } from "./components/select-list.js"; +export { type SettingItem, SettingsList, type SettingsListTheme } from "./components/settings-list.js"; export { Spacer } from "./components/spacer.js"; export { Text } from "./components/text.js"; export { TruncatedText } from "./components/truncated-text.js"; From 66d19ac9c87583e78a6343b0c13e58a068b7ff1c Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 25 Dec 2025 18:07:42 +0100 Subject: [PATCH 2/2] Fix jiti alias for typebox subpath imports in custom tools/hooks fixes #311 --- packages/coding-agent/CHANGELOG.md | 4 + .../src/core/custom-tools/loader.ts | 73 ++++++++++++++++++- .../coding-agent/src/core/hooks/loader.ts | 10 ++- 3 files changed, 83 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 513343bd..3f89345c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- **Custom tools/hooks with typebox subpath imports**: Fixed jiti alias for `@sinclair/typebox` to point to package root instead of entry file, allowing imports like `@sinclair/typebox/compiler` to resolve correctly. ([#311](https://github.com/badlogic/pi-mono/issues/311) by [@kim0](https://github.com/kim0)) + ## [0.29.0] - 2025-12-25 ### Breaking Changes diff --git a/packages/coding-agent/src/core/custom-tools/loader.ts b/packages/coding-agent/src/core/custom-tools/loader.ts index a56b3443..095f41c8 100644 --- a/packages/coding-agent/src/core/custom-tools/loader.ts +++ b/packages/coding-agent/src/core/custom-tools/loader.ts @@ -1,5 +1,10 @@ /** * Custom tool loader - loads TypeScript tool modules using jiti. + * + * For Bun compiled binaries, custom tools that import from @mariozechner/* packages + * are not supported because Bun's plugin system doesn't intercept imports from + * external files loaded at runtime. Users should use the npm-installed version + * for custom tools that depend on pi packages. */ import { spawn } from "node:child_process"; @@ -9,7 +14,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { createJiti } from "jiti"; -import { getAgentDir } from "../../config.js"; +import { getAgentDir, isBunBinary } from "../../config.js"; import type { HookUIContext } from "../hooks/types.js"; import type { CustomToolFactory, @@ -31,11 +36,19 @@ function getAliases(): Record { const __dirname = path.dirname(fileURLToPath(import.meta.url)); const packageIndex = path.resolve(__dirname, "../..", "index.js"); + // For typebox, we need the package root directory (not the entry file) + // because jiti's alias is prefix-based: imports like "@sinclair/typebox/compiler" + // get the alias prepended. If we alias to the entry file (.../build/cjs/index.js), + // then "@sinclair/typebox/compiler" becomes ".../build/cjs/index.js/compiler" (invalid). + // By aliasing to the package root, it becomes ".../typebox/compiler" which resolves correctly. + const typeboxEntry = require.resolve("@sinclair/typebox"); + const typeboxRoot = typeboxEntry.replace(/\/build\/cjs\/index\.js$/, ""); + _aliases = { "@mariozechner/pi-coding-agent": packageIndex, "@mariozechner/pi-tui": require.resolve("@mariozechner/pi-tui"), "@mariozechner/pi-ai": require.resolve("@mariozechner/pi-ai"), - "@sinclair/typebox": require.resolve("@sinclair/typebox"), + "@sinclair/typebox": typeboxRoot, }; return _aliases; } @@ -169,7 +182,56 @@ function createNoOpUIContext(): HookUIContext { } /** - * Load a single tool module using jiti. + * Load a tool in Bun binary mode. + * + * Since Bun plugins don't work for dynamically loaded external files, + * custom tools that import from @mariozechner/* packages won't work. + * Tools that only use standard npm packages (installed in the tool's directory) + * may still work. + */ +async function loadToolWithBun( + resolvedPath: string, + sharedApi: ToolAPI, +): Promise<{ tools: LoadedCustomTool[] | null; error: string | null }> { + try { + // Try to import directly - will work for tools without @mariozechner/* imports + const module = await import(resolvedPath); + const factory = (module.default ?? module) as CustomToolFactory; + + if (typeof factory !== "function") { + return { tools: null, error: "Tool must export a default function" }; + } + + const toolResult = await factory(sharedApi); + const toolsArray = Array.isArray(toolResult) ? toolResult : [toolResult]; + + const loadedTools: LoadedCustomTool[] = toolsArray.map((tool) => ({ + path: resolvedPath, + resolvedPath, + tool, + })); + + return { tools: loadedTools, error: null }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + + // Check if it's a module resolution error for our packages + if (message.includes("Cannot find module") && message.includes("@mariozechner/")) { + return { + tools: null, + error: + `${message}\n` + + "Note: Custom tools importing from @mariozechner/* packages are not supported in the standalone binary.\n" + + "Please install pi via npm: npm install -g @mariozechner/pi-coding-agent", + }; + } + + return { tools: null, error: `Failed to load tool: ${message}` }; + } +} + +/** + * Load a single tool module using jiti (or Bun.build for compiled binaries). */ async function loadTool( toolPath: string, @@ -178,6 +240,11 @@ async function loadTool( ): Promise<{ tools: LoadedCustomTool[] | null; error: string | null }> { const resolvedPath = resolveToolPath(toolPath, cwd); + // Use Bun.build for compiled binaries since jiti can't resolve bundled modules + if (isBunBinary) { + return loadToolWithBun(resolvedPath, sharedApi); + } + try { // Create jiti instance for TypeScript/ESM loading // Use aliases to resolve package imports since tools are loaded from user directories diff --git a/packages/coding-agent/src/core/hooks/loader.ts b/packages/coding-agent/src/core/hooks/loader.ts index 1fdd8ffa..18e09c19 100644 --- a/packages/coding-agent/src/core/hooks/loader.ts +++ b/packages/coding-agent/src/core/hooks/loader.ts @@ -23,12 +23,20 @@ function getAliases(): Record { const __dirname = path.dirname(fileURLToPath(import.meta.url)); const packageIndex = path.resolve(__dirname, "../..", "index.js"); + // For typebox, we need the package root directory (not the entry file) + // because jiti's alias is prefix-based: imports like "@sinclair/typebox/compiler" + // get the alias prepended. If we alias to the entry file (.../build/cjs/index.js), + // then "@sinclair/typebox/compiler" becomes ".../build/cjs/index.js/compiler" (invalid). + // By aliasing to the package root, it becomes ".../typebox/compiler" which resolves correctly. + const typeboxEntry = require.resolve("@sinclair/typebox"); + const typeboxRoot = typeboxEntry.replace(/\/build\/cjs\/index\.js$/, ""); + _aliases = { "@mariozechner/pi-coding-agent": packageIndex, "@mariozechner/pi-coding-agent/hooks": path.resolve(__dirname, "index.js"), "@mariozechner/pi-tui": require.resolve("@mariozechner/pi-tui"), "@mariozechner/pi-ai": require.resolve("@mariozechner/pi-ai"), - "@sinclair/typebox": require.resolve("@sinclair/typebox"), + "@sinclair/typebox": typeboxRoot, }; return _aliases; }