diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c29cdb90..e9b8cab1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- `model_select` extension hook fires when model changes via `/model`, model cycling, or session restore with `source` field and `previousModel` ([#628](https://github.com/badlogic/pi-mono/pull/628) by [@marckrenn](https://github.com/marckrenn)) - `ctx.ui.setWorkingMessage()` extension API to customize the "Working..." message during streaming ([#625](https://github.com/badlogic/pi-mono/pull/625) by [@nicobailon](https://github.com/nicobailon)) - Skill slash commands: loaded skills are registered as `/skill:name` commands for quick access. Toggle via `/settings` or `skills.enableSkillCommands` in settings.json. ([#630](https://github.com/badlogic/pi-mono/pull/630) by [@Dwsy](https://github.com/Dwsy)) - Slash command autocomplete now uses fuzzy matching (type `/skbra` to match `/skill:brave-search`) diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 00ea5259..7db3085d 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -290,6 +290,9 @@ user sends another prompt ◄───────────────── ├─► session_before_tree (can cancel or customize) └─► session_tree +/model or Ctrl+P (model selection/cycling) + └─► model_select + exit (Ctrl+C, Ctrl+D) └─► session_shutdown ``` @@ -481,6 +484,31 @@ pi.on("context", async (event, ctx) => { **Examples:** [plan-mode.ts](../examples/extensions/plan-mode.ts) +### Model Events + +#### model_select + +Fired when the model changes via `/model` command, model cycling (`Ctrl+P`), or session restore. + +```typescript +pi.on("model_select", async (event, ctx) => { + // event.model - newly selected model + // event.previousModel - previous model (undefined if first selection) + // event.source - "set" | "cycle" | "restore" + + const prev = event.previousModel + ? `${event.previousModel.provider}/${event.previousModel.id}` + : "none"; + const next = `${event.model.provider}/${event.model.id}`; + + ctx.ui.notify(`Model changed (${event.source}): ${prev} -> ${next}`, "info"); +}); +``` + +Use this to update UI elements (status bars, footers) or perform model-specific initialization when the active model changes. + +**Examples:** [model-status.ts](../examples/extensions/model-status.ts) + ### Tool Events #### tool_call diff --git a/packages/coding-agent/examples/extensions/model-status.ts b/packages/coding-agent/examples/extensions/model-status.ts new file mode 100644 index 00000000..bcbb733b --- /dev/null +++ b/packages/coding-agent/examples/extensions/model-status.ts @@ -0,0 +1,31 @@ +/** + * Model status extension - shows model changes in the status bar. + * + * Demonstrates the `model_select` hook which fires when the model changes + * via /model command, Ctrl+P cycling, or session restore. + * + * Usage: pi -e ./model-status.ts + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + pi.on("model_select", async (event, ctx) => { + const { model, previousModel, source } = event; + + // Format model identifiers + const next = `${model.provider}/${model.id}`; + const prev = previousModel ? `${previousModel.provider}/${previousModel.id}` : "none"; + + // Show notification on change + if (source !== "restore") { + ctx.ui.notify(`Model: ${next}`, "info"); + } + + // Update status bar with current model + ctx.ui.setStatus("model", `🤖 ${model.id}`); + + // Log change details (visible in debug output) + console.log(`[model_select] ${prev} → ${next} (${source})`); + }); +} diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 66db0f79..6776cc4c 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -947,6 +947,21 @@ export class AgentSession { // Model Management // ========================================================================= + private async _emitModelSelect( + nextModel: Model, + previousModel: Model | undefined, + source: "set" | "cycle" | "restore", + ): Promise { + if (!this._extensionRunner) return; + if (modelsAreEqual(previousModel, nextModel)) return; + await this._extensionRunner.emit({ + type: "model_select", + model: nextModel, + previousModel, + source, + }); + } + /** * Set model directly. * Validates API key, saves to session and settings. @@ -958,12 +973,15 @@ export class AgentSession { throw new Error(`No API key for ${model.provider}/${model.id}`); } + const previousModel = this.model; this.agent.setModel(model); this.sessionManager.appendModelChange(model.provider, model.id); this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); // Re-clamp thinking level for new model's capabilities this.setThinkingLevel(this.thinkingLevel); + + await this._emitModelSelect(model, previousModel, "set"); } /** @@ -1004,6 +1022,8 @@ export class AgentSession { // Apply thinking level (setThinkingLevel clamps to model capabilities) this.setThinkingLevel(next.thinkingLevel); + await this._emitModelSelect(next.model, currentModel, "cycle"); + return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true }; } @@ -1031,6 +1051,8 @@ export class AgentSession { // Re-clamp thinking level for new model's capabilities this.setThinkingLevel(this.thinkingLevel); + await this._emitModelSelect(nextModel, currentModel, "cycle"); + return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false }; } @@ -1783,12 +1805,14 @@ export class AgentSession { // Restore model if saved if (sessionContext.model) { + const previousModel = this.model; const availableModels = await this._modelRegistry.getAvailable(); const match = availableModels.find( (m) => m.provider === sessionContext.model!.provider && m.id === sessionContext.model!.modelId, ); if (match) { this.agent.setModel(match); + await this._emitModelSelect(match, previousModel, "restore"); } } diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index 8a831f79..48c335e9 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -67,6 +67,8 @@ export type { // Message Rendering MessageRenderer, MessageRenderOptions, + ModelSelectEvent, + ModelSelectSource, ReadToolResultEvent, // Commands RegisteredCommand, diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index cb57b313..ae8e8356 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -403,6 +403,20 @@ export interface TurnEndEvent { toolResults: ToolResultMessage[]; } +// ============================================================================ +// Model Events +// ============================================================================ + +export type ModelSelectSource = "set" | "cycle" | "restore"; + +/** Fired when a new model is selected */ +export interface ModelSelectEvent { + type: "model_select"; + model: Model; + previousModel: Model | undefined; + source: ModelSelectSource; +} + // ============================================================================ // User Bash Events // ============================================================================ @@ -521,6 +535,7 @@ export type ExtensionEvent = | AgentEndEvent | TurnStartEvent | TurnEndEvent + | ModelSelectEvent | UserBashEvent | ToolCallEvent | ToolResultEvent; @@ -645,6 +660,7 @@ export interface ExtensionAPI { on(event: "agent_end", handler: ExtensionHandler): void; on(event: "turn_start", handler: ExtensionHandler): void; on(event: "turn_end", handler: ExtensionHandler): void; + on(event: "model_select", handler: ExtensionHandler): void; on(event: "tool_call", handler: ExtensionHandler): void; on(event: "tool_result", handler: ExtensionHandler): void; on(event: "user_bash", handler: ExtensionHandler): void;