handoff: Fix countdown and extract model helper

Amolith created

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.

Change summary

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(-)

Detailed changes

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();

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);
 	});

packages/handoff/src/model-utils.ts 🔗

@@ -0,0 +1,52 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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<SetModelResult> {
+	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<SetModelResult> {
+	const result = await parseAndSetModel(spec, ctx, pi);
+	if (!result.ok) {
+		ctx.ui.notify(result.detail, "warning");
+	}
+	return result;
+}