diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index bdfe242b..ac4c753e 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -43,9 +43,10 @@ jobs: run: | # npm ci only installs optional deps for the current platform (linux-x64) # We need all platform bindings for bun cross-compilation + # Use --force to bypass platform checks (os/cpu restrictions in package.json) # Clipboard bindings for all target platforms - npm install --no-save \ + npm install --no-save --force \ @crosscopy/clipboard-darwin-arm64@0.2.8 \ @crosscopy/clipboard-darwin-x64@0.2.8 \ @crosscopy/clipboard-linux-x64-gnu@0.2.8 \ @@ -53,7 +54,7 @@ jobs: @crosscopy/clipboard-win32-x64-msvc@0.2.8 # Sharp bindings for all target platforms - npm install --no-save \ + npm install --no-save --force \ @img/sharp-darwin-arm64@0.34.5 \ @img/sharp-darwin-x64@0.34.5 \ @img/sharp-linux-x64@0.34.5 \ diff --git a/package-lock.json b/package-lock.json index 37c429e2..09a04432 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7362,11 +7362,11 @@ }, "packages/agent": { "name": "@mariozechner/pi-agent-core", - "version": "0.37.5", + "version": "0.37.6", "license": "MIT", "dependencies": { - "@mariozechner/pi-ai": "^0.37.5", - "@mariozechner/pi-tui": "^0.37.5" + "@mariozechner/pi-ai": "^0.37.6", + "@mariozechner/pi-tui": "^0.37.6" }, "devDependencies": { "@types/node": "^24.3.0", @@ -7396,7 +7396,7 @@ }, "packages/ai": { "name": "@mariozechner/pi-ai", - "version": "0.37.5", + "version": "0.37.6", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.71.2", @@ -7441,13 +7441,13 @@ }, "packages/coding-agent": { "name": "@mariozechner/pi-coding-agent", - "version": "0.37.5", + "version": "0.37.6", "license": "MIT", "dependencies": { "@crosscopy/clipboard": "^0.2.8", - "@mariozechner/pi-agent-core": "^0.37.5", - "@mariozechner/pi-ai": "^0.37.5", - "@mariozechner/pi-tui": "^0.37.5", + "@mariozechner/pi-agent-core": "^0.37.6", + "@mariozechner/pi-ai": "^0.37.6", + "@mariozechner/pi-tui": "^0.37.6", "chalk": "^5.5.0", "cli-highlight": "^2.1.11", "diff": "^8.0.2", @@ -7477,7 +7477,7 @@ }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "1.1.5", + "version": "1.1.6", "dependencies": { "ms": "^2.1.3" }, @@ -7504,13 +7504,13 @@ }, "packages/mom": { "name": "@mariozechner/pi-mom", - "version": "0.37.5", + "version": "0.37.6", "license": "MIT", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.16", - "@mariozechner/pi-agent-core": "^0.37.5", - "@mariozechner/pi-ai": "^0.37.5", - "@mariozechner/pi-coding-agent": "^0.37.5", + "@mariozechner/pi-agent-core": "^0.37.6", + "@mariozechner/pi-ai": "^0.37.6", + "@mariozechner/pi-coding-agent": "^0.37.6", "@sinclair/typebox": "^0.34.0", "@slack/socket-mode": "^2.0.0", "@slack/web-api": "^7.0.0", @@ -7549,10 +7549,10 @@ }, "packages/pods": { "name": "@mariozechner/pi", - "version": "0.37.5", + "version": "0.37.6", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.37.5", + "@mariozechner/pi-agent-core": "^0.37.6", "chalk": "^5.5.0" }, "bin": { @@ -7565,7 +7565,7 @@ }, "packages/tui": { "name": "@mariozechner/pi-tui", - "version": "0.37.5", + "version": "0.37.6", "license": "MIT", "dependencies": { "@types/mime-types": "^2.1.4", @@ -7609,12 +7609,12 @@ }, "packages/web-ui": { "name": "@mariozechner/pi-web-ui", - "version": "0.37.5", + "version": "0.37.6", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.37.5", - "@mariozechner/pi-tui": "^0.37.5", + "@mariozechner/pi-ai": "^0.37.6", + "@mariozechner/pi-tui": "^0.37.6", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0", @@ -7635,7 +7635,7 @@ }, "packages/web-ui/example": { "name": "pi-web-ui-example", - "version": "1.25.5", + "version": "1.25.6", "dependencies": { "@mariozechner/mini-lit": "^0.2.0", "@mariozechner/pi-ai": "file:../../ai", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index e371d005..4f087c36 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [0.37.6] - 2026-01-06 + ## [0.37.5] - 2026-01-06 ## [0.37.4] - 2026-01-06 diff --git a/packages/agent/package.json b/packages/agent/package.json index 084f4d31..d10d2bda 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-agent-core", - "version": "0.37.5", + "version": "0.37.6", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -17,8 +17,8 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@mariozechner/pi-ai": "^0.37.5", - "@mariozechner/pi-tui": "^0.37.5" + "@mariozechner/pi-ai": "^0.37.6", + "@mariozechner/pi-tui": "^0.37.6" }, "keywords": [ "ai", diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 56dbeb95..dbda86d1 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [0.37.6] - 2026-01-06 + +### Added + +- Exported OpenAI Codex utilities: `CacheMetadata`, `getCodexInstructions`, `getModelFamily`, `ModelFamily`, `buildCodexPiBridge`, `buildCodexSystemPrompt`, `CodexSystemPrompt` ([#510](https://github.com/badlogic/pi-mono/pull/510) by [@mitsuhiko](https://github.com/mitsuhiko)) + ## [0.37.5] - 2026-01-06 ## [0.37.4] - 2026-01-06 diff --git a/packages/ai/package.json b/packages/ai/package.json index 317875b9..4f869dba 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-ai", - "version": "0.37.5", + "version": "0.37.6", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 0fab4dbd..73a56538 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -3,6 +3,7 @@ export * from "./providers/anthropic.js"; export * from "./providers/google.js"; export * from "./providers/google-gemini-cli.js"; export * from "./providers/google-vertex.js"; +export * from "./providers/openai-codex/index.js"; export * from "./providers/openai-completions.js"; export * from "./providers/openai-responses.js"; export * from "./stream.js"; diff --git a/packages/ai/src/providers/openai-codex/index.ts b/packages/ai/src/providers/openai-codex/index.ts new file mode 100644 index 00000000..86575ded --- /dev/null +++ b/packages/ai/src/providers/openai-codex/index.ts @@ -0,0 +1,7 @@ +/** + * OpenAI Codex utilities - exported for use by coding-agent export + */ + +export { type CacheMetadata, getCodexInstructions, getModelFamily, type ModelFamily } from "./prompts/codex.js"; +export { buildCodexPiBridge } from "./prompts/pi-codex-bridge.js"; +export { buildCodexSystemPrompt, type CodexSystemPrompt } from "./prompts/system-prompt.js"; diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4e085b8f..3969a396 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +## [0.37.6] - 2026-01-06 + +### Added + +- Extension UI dialogs (`ctx.ui.select()`, `ctx.ui.confirm()`, `ctx.ui.input()`) now accept an optional `AbortSignal` to programmatically dismiss dialogs. Useful for implementing timeouts. See `examples/extensions/timed-confirm.ts`. ([#474](https://github.com/badlogic/pi-mono/issues/474)) +- HTML export now shows bridge prompts in model change messages for Codex sessions ([#510](https://github.com/badlogic/pi-mono/pull/510) by [@mitsuhiko](https://github.com/mitsuhiko)) + ## [0.37.5] - 2026-01-06 ### Added diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 5c0a3362..b8bdc60d 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -1094,6 +1094,38 @@ ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error" - `ctx.ui.editor()`: [handoff.ts](../examples/extensions/handoff.ts) - `ctx.ui.setEditorText()`: [handoff.ts](../examples/extensions/handoff.ts), [qna.ts](../examples/extensions/qna.ts) +#### Auto-Dismissing Dialogs + +Dialogs can be programmatically dismissed using `AbortSignal`. This is useful for implementing timeouts: + +```typescript +const controller = new AbortController(); +const timeoutId = setTimeout(() => controller.abort(), 5000); + +const confirmed = await ctx.ui.confirm( + "Timed Confirmation", + "This dialog will auto-cancel in 5 seconds. Confirm?", + { signal: controller.signal } +); + +clearTimeout(timeoutId); + +if (confirmed) { + // User confirmed +} else if (controller.signal.aborted) { + // Dialog timed out +} else { + // User cancelled (pressed Escape or selected "No") +} +``` + +**Return values on abort:** +- `select()` returns `undefined` +- `confirm()` returns `false` +- `input()` returns `undefined` + +See [examples/extensions/timed-confirm.ts](../examples/extensions/timed-confirm.ts) for a complete example. + ### Widgets, Status, and Footer ```typescript diff --git a/packages/coding-agent/examples/extensions/README.md b/packages/coding-agent/examples/extensions/README.md index 80909b41..91ce0918 100644 --- a/packages/coding-agent/examples/extensions/README.md +++ b/packages/coding-agent/examples/extensions/README.md @@ -44,6 +44,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/ | `status-line.ts` | Shows turn progress in footer via `ctx.ui.setStatus()` with themed colors | | `snake.ts` | Snake game with custom UI, keyboard handling, and session persistence | | `send-user-message.ts` | Demonstrates `pi.sendUserMessage()` for sending user messages from extensions | +| `timed-confirm.ts` | Demonstrates AbortSignal for auto-dismissing `ctx.ui.confirm()` and `ctx.ui.select()` dialogs | ### Git Integration diff --git a/packages/coding-agent/examples/extensions/timed-confirm.ts b/packages/coding-agent/examples/extensions/timed-confirm.ts new file mode 100644 index 00000000..138996c2 --- /dev/null +++ b/packages/coding-agent/examples/extensions/timed-confirm.ts @@ -0,0 +1,63 @@ +/** + * Example extension demonstrating AbortSignal for auto-dismissing dialogs. + * + * Commands: + * - /timed - Shows confirm dialog that auto-cancels after 5 seconds + * - /timed-select - Shows select dialog that auto-cancels after 10 seconds + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + pi.registerCommand("timed", { + description: "Show a timed confirmation dialog (auto-cancels in 5s)", + handler: async (_args, ctx) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + ctx.ui.notify("Dialog will auto-cancel in 5 seconds...", "info"); + + const confirmed = await ctx.ui.confirm( + "Timed Confirmation", + "This dialog will auto-cancel in 5 seconds. Confirm?", + { signal: controller.signal }, + ); + + clearTimeout(timeoutId); + + if (confirmed) { + ctx.ui.notify("Confirmed by user!", "info"); + } else if (controller.signal.aborted) { + ctx.ui.notify("Dialog timed out (auto-cancelled)", "warning"); + } else { + ctx.ui.notify("Cancelled by user", "info"); + } + }, + }); + + pi.registerCommand("timed-select", { + description: "Show a timed select dialog (auto-cancels in 10s)", + handler: async (_args, ctx) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + ctx.ui.notify("Select dialog will auto-cancel in 10 seconds...", "info"); + + const choice = await ctx.ui.select( + "Pick an option (auto-cancels in 10s)", + ["Option A", "Option B", "Option C"], + { signal: controller.signal }, + ); + + clearTimeout(timeoutId); + + if (choice) { + ctx.ui.notify(`Selected: ${choice}`, "info"); + } else if (controller.signal.aborted) { + ctx.ui.notify("Selection timed out", "warning"); + } else { + ctx.ui.notify("Selection cancelled", "info"); + } + }, + }); +} diff --git a/packages/coding-agent/examples/extensions/with-deps/package-lock.json b/packages/coding-agent/examples/extensions/with-deps/package-lock.json index bc8efe70..04c80094 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package-lock.json +++ b/packages/coding-agent/examples/extensions/with-deps/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-with-deps", - "version": "1.1.5", + "version": "1.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "1.1.5", + "version": "1.1.6", "dependencies": { "ms": "^2.1.3" }, diff --git a/packages/coding-agent/examples/extensions/with-deps/package.json b/packages/coding-agent/examples/extensions/with-deps/package.json index 96950728..49b1c917 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package.json +++ b/packages/coding-agent/examples/extensions/with-deps/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-with-deps", "private": true, - "version": "1.1.5", + "version": "1.1.6", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 11fd1622..73f47585 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-coding-agent", - "version": "0.37.5", + "version": "0.37.6", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -39,9 +39,9 @@ }, "dependencies": { "@crosscopy/clipboard": "^0.2.8", - "@mariozechner/pi-agent-core": "^0.37.5", - "@mariozechner/pi-ai": "^0.37.5", - "@mariozechner/pi-tui": "^0.37.5", + "@mariozechner/pi-agent-core": "^0.37.6", + "@mariozechner/pi-ai": "^0.37.6", + "@mariozechner/pi-tui": "^0.37.6", "chalk": "^5.5.0", "cli-highlight": "^2.1.11", "diff": "^8.0.2", diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 60868127..9deedc53 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -2057,9 +2057,9 @@ export class AgentSession { * @param outputPath Optional output path (defaults to session directory) * @returns Path to exported file */ - exportToHtml(outputPath?: string): string { + async exportToHtml(outputPath?: string): Promise { const themeName = this.settingsManager.getTheme(); - return exportSessionToHtml(this.sessionManager, this.state, { outputPath, themeName }); + return await exportSessionToHtml(this.sessionManager, this.state, { outputPath, themeName }); } // ========================================================================= diff --git a/packages/coding-agent/src/core/export-html/index.ts b/packages/coding-agent/src/core/export-html/index.ts index 577f262c..a25d39b7 100644 --- a/packages/coding-agent/src/core/export-html/index.ts +++ b/packages/coding-agent/src/core/export-html/index.ts @@ -1,4 +1,5 @@ -import type { AgentState } from "@mariozechner/pi-agent-core"; +import type { AgentState, AgentTool } from "@mariozechner/pi-agent-core"; +import { buildCodexPiBridge, getCodexInstructions } from "@mariozechner/pi-ai"; import { existsSync, readFileSync, writeFileSync } from "fs"; import { basename, join } from "path"; import { APP_NAME, getExportTemplateDir } from "../../config.js"; @@ -10,6 +11,37 @@ export interface ExportOptions { themeName?: string; } +/** Info about Codex injection to show inline with model_change entries */ +interface CodexInjectionInfo { + /** Codex instructions text */ + instructions: string; + /** Bridge text (tool list) */ + bridge: string; +} + +/** + * Build Codex injection info for display inline with model_change entries. + */ +async function buildCodexInjectionInfo(tools?: AgentTool[]): Promise { + // Try to get cached instructions for default model family + let instructions: string | null = null; + try { + instructions = await getCodexInstructions("gpt-5.1-codex"); + } catch { + // Cache miss - that's fine + } + + const bridgeText = buildCodexPiBridge(tools); + + const instructionsText = + instructions || "(Codex instructions not cached. Run a Codex request to populate the local cache.)"; + + return { + instructions: instructionsText, + bridge: bridgeText, + }; +} + /** Parse a color string to RGB values. Supports hex (#RRGGBB) and rgb(r,g,b) formats. */ function parseColor(color: string): { r: number; g: number; b: number } | undefined { const hexMatch = color.match(/^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/); @@ -103,6 +135,8 @@ interface SessionData { entries: ReturnType; leafId: string | null; systemPrompt?: string; + /** Info for rendering Codex injection inline with model_change entries */ + codexInjectionInfo?: CodexInjectionInfo; tools?: { name: string; description: string }[]; } @@ -146,7 +180,11 @@ function generateHtml(sessionData: SessionData, themeName?: string): string { * Export session to HTML using SessionManager and AgentState. * Used by TUI's /export command. */ -export function exportSessionToHtml(sm: SessionManager, state?: AgentState, options?: ExportOptions | string): string { +export async function exportSessionToHtml( + sm: SessionManager, + state?: AgentState, + options?: ExportOptions | string, +): Promise { const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {}; const sessionFile = sm.getSessionFile(); @@ -162,6 +200,7 @@ export function exportSessionToHtml(sm: SessionManager, state?: AgentState, opti entries: sm.getEntries(), leafId: sm.getLeafId(), systemPrompt: state?.systemPrompt, + codexInjectionInfo: await buildCodexInjectionInfo(state?.tools), tools: state?.tools?.map((t) => ({ name: t.name, description: t.description })), }; @@ -181,7 +220,7 @@ export function exportSessionToHtml(sm: SessionManager, state?: AgentState, opti * Export session file to HTML (standalone, without AgentState). * Used by CLI for exporting arbitrary session files. */ -export function exportFromFile(inputPath: string, options?: ExportOptions | string): string { +export async function exportFromFile(inputPath: string, options?: ExportOptions | string): Promise { const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {}; if (!existsSync(inputPath)) { @@ -195,6 +234,7 @@ export function exportFromFile(inputPath: string, options?: ExportOptions | stri entries: sm.getEntries(), leafId: sm.getLeafId(), systemPrompt: undefined, + codexInjectionInfo: await buildCodexInjectionInfo(undefined), tools: undefined, }; diff --git a/packages/coding-agent/src/core/export-html/template.css b/packages/coding-agent/src/core/export-html/template.css index 8fbdcd01..354ae74a 100644 --- a/packages/coding-agent/src/core/export-html/template.css +++ b/packages/coding-agent/src/core/export-html/template.css @@ -512,6 +512,39 @@ font-weight: bold; } + .codex-bridge-toggle { + color: var(--muted); + cursor: pointer; + text-decoration: underline; + font-size: 10px; + } + + .codex-bridge-toggle:hover { + color: var(--accent); + } + + .codex-bridge-content { + display: none; + margin-top: 8px; + padding: 8px; + background: var(--exportCardBg); + border-radius: 4px; + font-size: 11px; + max-height: 300px; + overflow: auto; + } + + .codex-bridge-content pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + color: var(--muted); + } + + .model-change.show-bridge .codex-bridge-content { + display: block; + } + /* Compaction / Branch Summary - matches customMessage colors from TUI */ .compaction { background: var(--customMessageBg); @@ -593,6 +626,17 @@ display: block; } + .system-prompt.provider-prompt { + border-left: 3px solid var(--warning); + } + + .system-prompt-note { + font-size: 10px; + font-style: italic; + color: var(--muted); + margin-top: 4px; + } + /* Tools list */ .tools-list { background: var(--customMessageBg); diff --git a/packages/coding-agent/src/core/export-html/template.js b/packages/coding-agent/src/core/export-html/template.js index 6d3e5929..48311d87 100644 --- a/packages/coding-agent/src/core/export-html/template.js +++ b/packages/coding-agent/src/core/export-html/template.js @@ -12,7 +12,7 @@ bytes[i] = binary.charCodeAt(i); } const data = JSON.parse(new TextDecoder('utf-8').decode(bytes)); - const { header, entries, leafId: defaultLeafId, systemPrompt, tools } = data; + const { header, entries, leafId: defaultLeafId, systemPrompt, codexInjectionInfo, tools } = data; // ============================================================ // URL PARAMETER HANDLING @@ -954,7 +954,17 @@ } if (entry.type === 'model_change') { - return `
${tsHtml}Switched to model: ${escapeHtml(entry.provider)}/${escapeHtml(entry.modelId)}
`; + let html = `
${tsHtml}Switched to model: ${escapeHtml(entry.provider)}/${escapeHtml(entry.modelId)}`; + + // Show expandable bridge prompt info when switching to openai-codex + if (entry.provider === 'openai-codex' && codexInjectionInfo) { + const fullContent = `# Codex Instructions\n${codexInjectionInfo.instructions}\n\n# Codex-Pi Bridge\n${codexInjectionInfo.bridge}`; + html += ` [bridge prompt]`; + html += `
${escapeHtml(fullContent)}
`; + } + + html += '
'; + return html; } if (entry.type === 'compaction') { @@ -1060,6 +1070,7 @@ `; + // Render system prompt (user's base prompt, applies to all providers) if (systemPrompt) { const lines = systemPrompt.split('\n'); const previewLines = 10; diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 96e095d2..28b05f3e 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -52,13 +52,13 @@ export type { AgentToolResult, AgentToolUpdateCallback }; */ export interface ExtensionUIContext { /** Show a selector and return the user's choice. */ - select(title: string, options: string[]): Promise; + select(title: string, options: string[], opts?: { signal?: AbortSignal }): Promise; /** Show a confirmation dialog. */ - confirm(title: string, message: string): Promise; + confirm(title: string, message: string, opts?: { signal?: AbortSignal }): Promise; /** Show a text input dialog. */ - input(title: string, placeholder?: string): Promise; + input(title: string, placeholder?: string, opts?: { signal?: AbortSignal }): Promise; /** Show a notification to the user. */ notify(message: string, type?: "info" | "warning" | "error"): void; diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 92754958..da8d0e6c 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -368,7 +368,7 @@ export async function main(args: string[]) { if (parsed.export) { try { const outputPath = parsed.messages.length > 0 ? parsed.messages[0] : undefined; - const result = exportFromFile(parsed.export, outputPath); + const result = await exportFromFile(parsed.export, outputPath); console.log(`Exported to: ${result}`); return; } catch (error: unknown) { diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index e5a1af3d..fb8fedb4 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -739,9 +739,9 @@ export class InteractiveMode { */ private createExtensionUIContext(): ExtensionUIContext { return { - select: (title, options) => this.showExtensionSelector(title, options), - confirm: (title, message) => this.showExtensionConfirm(title, message), - input: (title, placeholder) => this.showExtensionInput(title, placeholder), + select: (title, options, opts) => this.showExtensionSelector(title, options, opts), + confirm: (title, message, opts) => this.showExtensionConfirm(title, message, opts), + input: (title, placeholder, opts) => this.showExtensionInput(title, placeholder, opts), notify: (message, type) => this.showExtensionNotify(message, type), setStatus: (key, text) => this.setExtensionStatus(key, text), setWidget: (key, content) => this.setExtensionWidget(key, content), @@ -761,16 +761,33 @@ export class InteractiveMode { /** * Show a selector for extensions. */ - private showExtensionSelector(title: string, options: string[]): Promise { + private showExtensionSelector( + title: string, + options: string[], + opts?: { signal?: AbortSignal }, + ): Promise { return new Promise((resolve) => { + if (opts?.signal?.aborted) { + resolve(undefined); + return; + } + + const onAbort = () => { + this.hideExtensionSelector(); + resolve(undefined); + }; + opts?.signal?.addEventListener("abort", onAbort, { once: true }); + this.extensionSelector = new ExtensionSelectorComponent( title, options, (option) => { + opts?.signal?.removeEventListener("abort", onAbort); this.hideExtensionSelector(); resolve(option); }, () => { + opts?.signal?.removeEventListener("abort", onAbort); this.hideExtensionSelector(); resolve(undefined); }, @@ -797,24 +814,45 @@ export class InteractiveMode { /** * Show a confirmation dialog for extensions. */ - private async showExtensionConfirm(title: string, message: string): Promise { - const result = await this.showExtensionSelector(`${title}\n${message}`, ["Yes", "No"]); + private async showExtensionConfirm( + title: string, + message: string, + opts?: { signal?: AbortSignal }, + ): Promise { + const result = await this.showExtensionSelector(`${title}\n${message}`, ["Yes", "No"], opts); return result === "Yes"; } /** * Show a text input for extensions. */ - private showExtensionInput(title: string, placeholder?: string): Promise { + private showExtensionInput( + title: string, + placeholder?: string, + opts?: { signal?: AbortSignal }, + ): Promise { return new Promise((resolve) => { + if (opts?.signal?.aborted) { + resolve(undefined); + return; + } + + const onAbort = () => { + this.hideExtensionInput(); + resolve(undefined); + }; + opts?.signal?.addEventListener("abort", onAbort, { once: true }); + this.extensionInput = new ExtensionInputComponent( title, placeholder, (value) => { + opts?.signal?.removeEventListener("abort", onAbort); this.hideExtensionInput(); resolve(value); }, () => { + opts?.signal?.removeEventListener("abort", onAbort); this.hideExtensionInput(); resolve(undefined); }, @@ -1051,7 +1089,7 @@ export class InteractiveMode { return; } if (text.startsWith("/export")) { - this.handleExportCommand(text); + await this.handleExportCommand(text); this.editor.setText(""); return; } @@ -2453,12 +2491,12 @@ export class InteractiveMode { // Command handlers // ========================================================================= - private handleExportCommand(text: string): void { + private async handleExportCommand(text: string): Promise { const parts = text.split(/\s+/); const outputPath = parts.length > 1 ? parts[1] : undefined; try { - const filePath = this.session.exportToHtml(outputPath); + const filePath = await this.session.exportToHtml(outputPath); this.showStatus(`Session exported to: ${filePath}`); } catch (error: unknown) { this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); @@ -2481,7 +2519,7 @@ export class InteractiveMode { // Export to a temp file const tmpFile = path.join(os.tmpdir(), "session.html"); try { - this.session.exportToHtml(tmpFile); + await this.session.exportToHtml(tmpFile); } catch (error: unknown) { this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); return; diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 8a429d5d..7c2668b3 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -67,11 +67,22 @@ export async function runRpcMode(session: AgentSession): Promise { * Create an extension UI context that uses the RPC protocol. */ const createExtensionUIContext = (): ExtensionUIContext => ({ - async select(title: string, options: string[]): Promise { + async select(title: string, options: string[], opts?: { signal?: AbortSignal }): Promise { + if (opts?.signal?.aborted) { + return undefined; + } + const id = crypto.randomUUID(); return new Promise((resolve, reject) => { + const onAbort = () => { + pendingExtensionRequests.delete(id); + resolve(undefined); + }; + opts?.signal?.addEventListener("abort", onAbort, { once: true }); + pendingExtensionRequests.set(id, { resolve: (response: RpcExtensionUIResponse) => { + opts?.signal?.removeEventListener("abort", onAbort); if ("cancelled" in response && response.cancelled) { resolve(undefined); } else if ("value" in response) { @@ -86,11 +97,22 @@ export async function runRpcMode(session: AgentSession): Promise { }); }, - async confirm(title: string, message: string): Promise { + async confirm(title: string, message: string, opts?: { signal?: AbortSignal }): Promise { + if (opts?.signal?.aborted) { + return false; + } + const id = crypto.randomUUID(); return new Promise((resolve, reject) => { + const onAbort = () => { + pendingExtensionRequests.delete(id); + resolve(false); + }; + opts?.signal?.addEventListener("abort", onAbort, { once: true }); + pendingExtensionRequests.set(id, { resolve: (response: RpcExtensionUIResponse) => { + opts?.signal?.removeEventListener("abort", onAbort); if ("cancelled" in response && response.cancelled) { resolve(false); } else if ("confirmed" in response) { @@ -105,11 +127,22 @@ export async function runRpcMode(session: AgentSession): Promise { }); }, - async input(title: string, placeholder?: string): Promise { + async input(title: string, placeholder?: string, opts?: { signal?: AbortSignal }): Promise { + if (opts?.signal?.aborted) { + return undefined; + } + const id = crypto.randomUUID(); return new Promise((resolve, reject) => { + const onAbort = () => { + pendingExtensionRequests.delete(id); + resolve(undefined); + }; + opts?.signal?.addEventListener("abort", onAbort, { once: true }); + pendingExtensionRequests.set(id, { resolve: (response: RpcExtensionUIResponse) => { + opts?.signal?.removeEventListener("abort", onAbort); if ("cancelled" in response && response.cancelled) { resolve(undefined); } else if ("value" in response) { @@ -443,7 +476,7 @@ export async function runRpcMode(session: AgentSession): Promise { } case "export_html": { - const path = session.exportToHtml(command.outputPath); + const path = await session.exportToHtml(command.outputPath); return success(id, "export_html", { path }); } diff --git a/packages/mom/CHANGELOG.md b/packages/mom/CHANGELOG.md index 20e9b783..1d5701d9 100644 --- a/packages/mom/CHANGELOG.md +++ b/packages/mom/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [0.37.6] - 2026-01-06 + ## [0.37.5] - 2026-01-06 ## [0.37.4] - 2026-01-06 diff --git a/packages/mom/package.json b/packages/mom/package.json index c8919a4f..00933d91 100644 --- a/packages/mom/package.json +++ b/packages/mom/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-mom", - "version": "0.37.5", + "version": "0.37.6", "description": "Slack bot that delegates messages to the pi coding agent", "type": "module", "bin": { @@ -20,9 +20,9 @@ }, "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.16", - "@mariozechner/pi-agent-core": "^0.37.5", - "@mariozechner/pi-ai": "^0.37.5", - "@mariozechner/pi-coding-agent": "^0.37.5", + "@mariozechner/pi-agent-core": "^0.37.6", + "@mariozechner/pi-ai": "^0.37.6", + "@mariozechner/pi-coding-agent": "^0.37.6", "@sinclair/typebox": "^0.34.0", "@slack/socket-mode": "^2.0.0", "@slack/web-api": "^7.0.0", diff --git a/packages/pods/package.json b/packages/pods/package.json index e1650b56..f9a5cf5a 100644 --- a/packages/pods/package.json +++ b/packages/pods/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi", - "version": "0.37.5", + "version": "0.37.6", "description": "CLI tool for managing vLLM deployments on GPU pods", "type": "module", "bin": { @@ -33,7 +33,7 @@ "node": ">=20.0.0" }, "dependencies": { - "@mariozechner/pi-agent-core": "^0.37.5", + "@mariozechner/pi-agent-core": "^0.37.6", "chalk": "^5.5.0" }, "devDependencies": {} diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 3caf73e8..6dacd5d1 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [0.37.6] - 2026-01-06 + +### Added + +- Kitty keyboard protocol flag 2 support for key release events. New exports: `isKeyRelease(data)`, `isKeyRepeat(data)`, `KeyEventType` type. Terminals supporting Kitty protocol (Kitty, Ghostty, WezTerm) now send proper key-up events. + ## [0.37.5] - 2026-01-06 ## [0.37.4] - 2026-01-06 diff --git a/packages/tui/package.json b/packages/tui/package.json index 7cd70dd7..47bfd59b 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-tui", - "version": "0.37.5", + "version": "0.37.6", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 2e1b51fb..23efc663 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -30,7 +30,17 @@ export { setEditorKeybindings, } from "./keybindings.js"; // Keyboard input handling -export { isKittyProtocolActive, Key, type KeyId, matchesKey, parseKey, setKittyProtocolActive } from "./keys.js"; +export { + isKeyRelease, + isKeyRepeat, + isKittyProtocolActive, + Key, + type KeyEventType, + type KeyId, + matchesKey, + parseKey, + setKittyProtocolActive, +} from "./keys.js"; // Terminal interface and implementations export { ProcessTerminal, type Terminal } from "./terminal.js"; // Terminal image support diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index 1c3e3d87..fcba53da 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -294,33 +294,99 @@ const FUNCTIONAL_CODEPOINTS = { // Kitty Protocol Parsing // ============================================================================= +/** + * Event types from Kitty keyboard protocol (flag 2) + * 1 = key press, 2 = key repeat, 3 = key release + */ +export type KeyEventType = "press" | "repeat" | "release"; + interface ParsedKittySequence { codepoint: number; modifier: number; + eventType: KeyEventType; +} + +// Store the last parsed event type for isKeyRelease() to query +let _lastEventType: KeyEventType = "press"; + +/** + * Check if the last parsed key event was a key release. + * Only meaningful when Kitty keyboard protocol with flag 2 is active. + */ +export function isKeyRelease(data: string): boolean { + // Quick check: release events with flag 2 contain ":3" + // Format: \x1b[;:3u + if ( + data.includes(":3u") || + data.includes(":3~") || + data.includes(":3A") || + data.includes(":3B") || + data.includes(":3C") || + data.includes(":3D") || + data.includes(":3H") || + data.includes(":3F") + ) { + return true; + } + return false; +} + +/** + * Check if the last parsed key event was a key repeat. + * Only meaningful when Kitty keyboard protocol with flag 2 is active. + */ +export function isKeyRepeat(data: string): boolean { + if ( + data.includes(":2u") || + data.includes(":2~") || + data.includes(":2A") || + data.includes(":2B") || + data.includes(":2C") || + data.includes(":2D") || + data.includes(":2H") || + data.includes(":2F") + ) { + return true; + } + return false; +} + +function parseEventType(eventTypeStr: string | undefined): KeyEventType { + if (!eventTypeStr) return "press"; + const eventType = parseInt(eventTypeStr, 10); + if (eventType === 2) return "repeat"; + if (eventType === 3) return "release"; + return "press"; } function parseKittySequence(data: string): ParsedKittySequence | null { - // CSI u format: \x1b[u or \x1b[;u - const csiUMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?u$/); + // CSI u format: \x1b[u or \x1b[;u or \x1b[;:u + // With flag 2, event type is appended after colon: 1=press, 2=repeat, 3=release + const csiUMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?(?::(\d+))?u$/); if (csiUMatch) { const codepoint = parseInt(csiUMatch[1]!, 10); const modValue = csiUMatch[2] ? parseInt(csiUMatch[2], 10) : 1; - return { codepoint, modifier: modValue - 1 }; + const eventType = parseEventType(csiUMatch[3]); + _lastEventType = eventType; + return { codepoint, modifier: modValue - 1, eventType }; } - // Arrow keys with modifier: \x1b[1;A/B/C/D - const arrowMatch = data.match(/^\x1b\[1;(\d+)([ABCD])$/); + // Arrow keys with modifier: \x1b[1;A/B/C/D or \x1b[1;:A/B/C/D + const arrowMatch = data.match(/^\x1b\[1;(\d+)(?::(\d+))?([ABCD])$/); if (arrowMatch) { const modValue = parseInt(arrowMatch[1]!, 10); + const eventType = parseEventType(arrowMatch[2]); const arrowCodes: Record = { A: -1, B: -2, C: -3, D: -4 }; - return { codepoint: arrowCodes[arrowMatch[2]!]!, modifier: modValue - 1 }; + _lastEventType = eventType; + return { codepoint: arrowCodes[arrowMatch[3]!]!, modifier: modValue - 1, eventType }; } - // Functional keys: \x1b[~ or \x1b[;~ - const funcMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?~$/); + // Functional keys: \x1b[~ or \x1b[;~ or \x1b[;:~ + const funcMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?(?::(\d+))?~$/); if (funcMatch) { const keyNum = parseInt(funcMatch[1]!, 10); const modValue = funcMatch[2] ? parseInt(funcMatch[2], 10) : 1; + const eventType = parseEventType(funcMatch[3]); const funcCodes: Record = { 2: FUNCTIONAL_CODEPOINTS.insert, 3: FUNCTIONAL_CODEPOINTS.delete, @@ -331,16 +397,19 @@ function parseKittySequence(data: string): ParsedKittySequence | null { }; const codepoint = funcCodes[keyNum]; if (codepoint !== undefined) { - return { codepoint, modifier: modValue - 1 }; + _lastEventType = eventType; + return { codepoint, modifier: modValue - 1, eventType }; } } - // Home/End with modifier: \x1b[1;H/F - const homeEndMatch = data.match(/^\x1b\[1;(\d+)([HF])$/); + // Home/End with modifier: \x1b[1;H/F or \x1b[1;:H/F + const homeEndMatch = data.match(/^\x1b\[1;(\d+)(?::(\d+))?([HF])$/); if (homeEndMatch) { const modValue = parseInt(homeEndMatch[1]!, 10); - const codepoint = homeEndMatch[2] === "H" ? FUNCTIONAL_CODEPOINTS.home : FUNCTIONAL_CODEPOINTS.end; - return { codepoint, modifier: modValue - 1 }; + const eventType = parseEventType(homeEndMatch[2]); + const codepoint = homeEndMatch[3] === "H" ? FUNCTIONAL_CODEPOINTS.home : FUNCTIONAL_CODEPOINTS.end; + _lastEventType = eventType; + return { codepoint, modifier: modValue - 1, eventType }; } return null; diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 8cda0350..65411880 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -109,7 +109,8 @@ export class ProcessTerminal implements Terminal { // Enable Kitty keyboard protocol (push flags) // Flag 1 = disambiguate escape codes - process.stdout.write("\x1b[>1u"); + // Flag 2 = report event types (press/repeat/release) + process.stdout.write("\x1b[>3u"); // Remove the response from buffer, forward any remaining input const remaining = buffer.replace(kittyResponsePattern, ""); diff --git a/packages/web-ui/CHANGELOG.md b/packages/web-ui/CHANGELOG.md index bdc895db..7627af7c 100644 --- a/packages/web-ui/CHANGELOG.md +++ b/packages/web-ui/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [0.37.6] - 2026-01-06 + ## [0.37.5] - 2026-01-06 ## [0.37.4] - 2026-01-06 diff --git a/packages/web-ui/example/package.json b/packages/web-ui/example/package.json index 9ac394e7..25f84246 100644 --- a/packages/web-ui/example/package.json +++ b/packages/web-ui/example/package.json @@ -1,6 +1,6 @@ { "name": "pi-web-ui-example", - "version": "1.25.5", + "version": "1.25.6", "private": true, "type": "module", "scripts": { diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index 0ec46bf1..a8cb662b 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-web-ui", - "version": "0.37.5", + "version": "0.37.6", "description": "Reusable web UI components for AI chat interfaces powered by @mariozechner/pi-ai", "type": "module", "main": "dist/index.js", @@ -18,8 +18,8 @@ }, "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.37.5", - "@mariozechner/pi-tui": "^0.37.5", + "@mariozechner/pi-ai": "^0.37.6", + "@mariozechner/pi-tui": "^0.37.6", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0",