From 4f52797761cfe470a0bd418efeeec4abd209230e Mon Sep 17 00:00:00 2001 From: Amolith Date: Mon, 6 Apr 2026 18:55:04 -0600 Subject: [PATCH] handoff: Fix countdown and extract model helper Cancel the countdown on any terminal input, not just "editable" keys. Arrow keys and other navigation are escape sequences that isEditableInput() explicitly rejected, so navigating the handoff draft didn't stop the auto-submit timer. Extract parseAndSetModel() into model-utils.ts to deduplicate the model-parsing logic shared between the agent_end handler in index.ts and the /handoff command. The shared helper also wraps pi.setModel() in try/catch, surfacing failures as user-visible warnings instead of unhandled promise rejections. --- packages/handoff/src/handoff-command.ts | 15 +------ packages/handoff/src/index.ts | 29 ++------------ packages/handoff/src/model-utils.ts | 52 +++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 38 deletions(-) create mode 100644 packages/handoff/src/model-utils.ts diff --git a/packages/handoff/src/handoff-command.ts b/packages/handoff/src/handoff-command.ts index fbdad35d6559b9cb4bc423bc2f1b98098c61fbc3..3d5aae7f1caf30f754651c3c41169ad2d4009ba9 100644 --- a/packages/handoff/src/handoff-command.ts +++ b/packages/handoff/src/handoff-command.ts @@ -12,6 +12,7 @@ import type { import { BorderedLoader, convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent"; import { extractCandidateFiles, extractLoadedSkills, resolveExtractionModel } from "./session-analysis.js"; import { type HandoffExtraction, assembleHandoffDraft, extractHandoffContext } from "./handoff-extraction.js"; +import { parseAndSetModelWithNotify } from "./model-utils.js"; export function registerHandoffCommand(pi: ExtensionAPI, startCountdown: (ctx: ExtensionContext) => void) { pi.registerCommand("handoff", { @@ -120,19 +121,7 @@ export function registerHandoffCommand(pi: ExtensionAPI, startCountdown: (ctx: E // Apply -model if specified if (newSessionModel) { - const slashIdx = newSessionModel.indexOf("/"); - if (slashIdx > 0) { - const provider = newSessionModel.slice(0, slashIdx); - const modelId = newSessionModel.slice(slashIdx + 1); - const model = ctx.modelRegistry.find(provider, modelId); - if (model) { - await pi.setModel(model); - } else { - ctx.ui.notify(`Unknown model: ${newSessionModel}`, "warning"); - } - } else { - ctx.ui.notify(`Invalid model format "${newSessionModel}", expected provider/modelId`, "warning"); - } + await parseAndSetModelWithNotify(newSessionModel, ctx, pi); } const newSessionFile = ctx.sessionManager.getSessionFile(); diff --git a/packages/handoff/src/index.ts b/packages/handoff/src/index.ts index a619ff6a09862d818a578a7baafda421f06d63a8..b7ecfb6198f21ea5e4b41691d3ffb5b79ab0c194 100644 --- a/packages/handoff/src/index.ts +++ b/packages/handoff/src/index.ts @@ -5,9 +5,10 @@ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import type { SessionManager } from "@mariozechner/pi-coding-agent"; -import { Key, matchesKey } from "@mariozechner/pi-tui"; +import { Key, matchesKey, parseKey } from "@mariozechner/pi-tui"; import { registerHandoffCommand } from "./handoff-command.js"; import { registerHandoffTool, type PendingHandoff } from "./handoff-tool.js"; +import { parseAndSetModelWithNotify } from "./model-utils.js"; import { registerSessionQueryTool } from "./session-query-tool.js"; const STATUS_KEY = "handoff"; @@ -20,22 +21,6 @@ type PendingAutoSubmit = { unsubscribeInput: () => void; }; -function isEditableInput(data: string): boolean { - if (!data) return false; - if (data.length === 1) { - const code = data.charCodeAt(0); - if (code >= 32 && code !== 127) return true; - if (code === 8 || code === 13) return true; - } - - if (data === "\n" || data === "\r") return true; - if (data === "\x7f") return true; - - if (data.length > 1 && !data.startsWith("\x1b")) return true; - - return false; -} - function statusLine(ctx: ExtensionContext, seconds: number): string { const accent = ctx.ui.theme.fg("accent", `handoff auto-submit in ${seconds}s`); const hint = ctx.ui.theme.fg("dim", "(type to edit, Esc to cancel)"); @@ -107,7 +92,7 @@ export default function (pi: ExtensionAPI) { return { consume: true }; } - if (isEditableInput(data)) { + if (parseKey(data) !== undefined) { clearPending(ctx, "Handoff auto-submit stopped (editing)"); } @@ -195,13 +180,7 @@ export default function (pi: ExtensionAPI) { startCountdown(ctx); if (newModel) { - const slashIdx = newModel.indexOf("/"); - if (slashIdx > 0) { - const provider = newModel.slice(0, slashIdx); - const modelId = newModel.slice(slashIdx + 1); - const model = ctx.modelRegistry.find(provider, modelId); - if (model) await pi.setModel(model); - } + await parseAndSetModelWithNotify(newModel, ctx, pi); } }, 0); }); diff --git a/packages/handoff/src/model-utils.ts b/packages/handoff/src/model-utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..34bbefb1ed82649edfdc539e8418057786ded4c0 --- /dev/null +++ b/packages/handoff/src/model-utils.ts @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: MIT + +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; + +export type SetModelResult = + | { ok: true } + | { ok: false; reason: "invalid-format" | "unknown-model" | "set-failed"; detail: string }; + +/** + * Parse a "provider/modelId" string, look it up in the model registry, and + * set it as the active model. Returns a result describing what happened. + */ +export async function parseAndSetModel(spec: string, ctx: ExtensionContext, pi: ExtensionAPI): Promise { + const slashIdx = spec.indexOf("/"); + if (slashIdx <= 0 || slashIdx === spec.length - 1) { + return { ok: false, reason: "invalid-format", detail: `Invalid model format "${spec}", expected provider/modelId` }; + } + + const provider = spec.slice(0, slashIdx); + const modelId = spec.slice(slashIdx + 1); + const model = ctx.modelRegistry.find(provider, modelId); + + if (!model) { + return { ok: false, reason: "unknown-model", detail: `Unknown model: ${spec}` }; + } + + try { + await pi.setModel(model); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { ok: false, reason: "set-failed", detail: `Failed to set model ${spec}: ${msg}` }; + } + + return { ok: true }; +} + +/** + * Convenience wrapper: parse and set the model, notifying the user on failure. + */ +export async function parseAndSetModelWithNotify( + spec: string, + ctx: ExtensionContext, + pi: ExtensionAPI, +): Promise { + const result = await parseAndSetModel(spec, ctx, pi); + if (!result.ok) { + ctx.ui.notify(result.detail, "warning"); + } + return result; +}