diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index bad9eab69..9c9b98b73 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] + ### Breaking Changes - Renamed hashline edit operation types: `append` → `append_at`, `prepend` → `prepend_at`, `append_eof` → `append_file`, `prepend_bof` → `prepend_file` @@ -10,6 +11,12 @@ ### Added +- Added git branch isolation for autoresearch sessions via `ensureAutoresearchBranch()` to safely revert failed experiments +- Added branch status line to autoresearch initialization and resume prompts showing created or reused branch name +- Added `Files in Scope`, `Off Limits`, and `Constraints` sections to autoresearch.md template for explicit scope definition +- Added validation of ASI metadata requirements in `log_experiment` tool, requiring hypothesis for all runs and rollback context for failed runs +- Added keybinding matcher utilities `matchesAppInterrupt()` and `matchesSelectCancel()` for consistent escape key handling across components +- Added support for customizable `app.interrupt` and `tui.select.cancel` keybindings in interactive components - Added `defaultInactive` property to `ToolDefinition` to allow tools to be registered but excluded from the initial active set, with extension responsibility for activation/deactivation - Added dynamic tool activation/deactivation in autoresearch mode via `setActiveTools()` API - Added separate initialization and resume workflows for autoresearch with `command-initialize.md` and `command-resume.md` prompts @@ -36,10 +43,17 @@ ### Changed +- Changed autoresearch startup to create or reuse a dedicated `autoresearch/...` git branch before enabling the experiment loop +- Changed autoresearch to refuse startup when unrelated worktree changes would make auto-reverts unsafe +- Changed autoresearch prompts to emphasize scope and constraints as source of truth for session direction +- Changed component escape key handling to use keybinding manager for `app.interrupt` and `tui.select.cancel` with fallback to raw Escape matching +- Updated autoresearch prompt guidance to require explicit files in scope, off-limits paths, and session constraints - Changed autoresearch command to use intent-based initialization instead of goal parameter, with user input dialog for new sessions +- Changed autoresearch startup to create or reuse a dedicated `autoresearch/...` git branch before enabling the experiment loop, and to refuse startup when unrelated worktree changes would make auto-reverts unsafe - Changed autoresearch startup to activate experiment tools (`init_experiment`, `run_experiment`, `log_experiment`) only when autoresearch mode is enabled - Changed autoresearch shutdown to deactivate experiment tools when mode is disabled or cleared - Changed autoresearch session rehydration to dynamically manage experiment tool activation based on session state +- Changed autoresearch prompts and notes guidance to require explicit files in scope, off-limits paths, and session constraints - Refactored hashline edit validation to enforce stricter anchor requirements per operation type - Updated edit application logic to handle explicit file-level operations (`append_eof`, `prepend_bof`) separately from anchor-based operations - Changed `setWidget` API to accept `ExtensionWidgetOptions` parameter for placement control @@ -62,6 +76,11 @@ - Removed `shouldAutocorrect` function and related boundary line deduplication logic from hashline editor - Removed auto-correction of off-by-one range edits that duplicated closing braces or boundary lines +### Fixed + +- Fixed autoresearch logging to require durable ASI metadata (hypothesis, rollback_reason, next_action_hint) for every run including rollback context for discarded, crashed, and checks-failed experiments +- Fixed autoresearch logging to require durable ASI metadata for every run, including rollback context for discarded, crashed, and checks-failed experiments + ## [13.14.0] - 2026-03-20 ### Added diff --git a/packages/coding-agent/src/autoresearch/command-initialize.md b/packages/coding-agent/src/autoresearch/command-initialize.md index d4791b1d2..271d9135a 100644 --- a/packages/coding-agent/src/autoresearch/command-initialize.md +++ b/packages/coding-agent/src/autoresearch/command-initialize.md @@ -2,10 +2,13 @@ Set up autoresearch for this intent: {{intent}} +{{branch_status_line}} + Explain briefly what autoresearch will do in this repository, then initialize the workspace. Your first actions: - write `autoresearch.md` +- define `Files in Scope`, `Off Limits`, and `Constraints` in `autoresearch.md` - define the benchmark entrypoint in `autoresearch.sh` - optionally add `autoresearch.checks.sh` if correctness or quality needs a hard gate - run `init_experiment` diff --git a/packages/coding-agent/src/autoresearch/command-resume.md b/packages/coding-agent/src/autoresearch/command-resume.md index 2ad853a94..46d8cc742 100644 --- a/packages/coding-agent/src/autoresearch/command-resume.md +++ b/packages/coding-agent/src/autoresearch/command-resume.md @@ -2,7 +2,9 @@ Resume autoresearch from the attached notes. @{{autoresearch_md_path}} -Use the notes as the source of truth for the current direction. +{{branch_status_line}} + +Use the notes as the source of truth for the current direction, scope, and constraints. - inspect recent git history for context - inspect `autoresearch.jsonl` if it exists - continue the most promising unfinished branch diff --git a/packages/coding-agent/src/autoresearch/git.ts b/packages/coding-agent/src/autoresearch/git.ts new file mode 100644 index 000000000..12caf3721 --- /dev/null +++ b/packages/coding-agent/src/autoresearch/git.ts @@ -0,0 +1,149 @@ +import type { ExtensionAPI } from "../extensibility/extensions"; +import { PROTECTED_AUTORESEARCH_FILES } from "./helpers"; + +const AUTORESEARCH_BRANCH_PREFIX = "autoresearch/"; +const BRANCH_NAME_MAX_LENGTH = 48; + +export interface EnsureAutoresearchBranchFailure { + error: string; + ok: false; +} + +export interface EnsureAutoresearchBranchSuccess { + branchName: string; + created: boolean; + ok: true; +} + +export type EnsureAutoresearchBranchResult = EnsureAutoresearchBranchFailure | EnsureAutoresearchBranchSuccess; + +export async function ensureAutoresearchBranch( + api: ExtensionAPI, + workDir: string, + goal: string | null, +): Promise { + const repoRootResult = await api.exec("git", ["rev-parse", "--show-toplevel"], { cwd: workDir, timeout: 5_000 }); + if (repoRootResult.code !== 0) { + return { + error: "Autoresearch requires a git repository so it can isolate experiments and revert failed runs safely.", + ok: false, + }; + } + + const currentBranchResult = await api.exec("git", ["branch", "--show-current"], { cwd: workDir, timeout: 5_000 }); + const currentBranch = currentBranchResult.stdout.trim(); + if (currentBranch.startsWith(AUTORESEARCH_BRANCH_PREFIX)) { + return { + branchName: currentBranch, + created: false, + ok: true, + }; + } + + const dirtyPathsResult = await api.exec("git", ["status", "--porcelain", "--untracked-files=all"], { + cwd: workDir, + timeout: 5_000, + }); + if (dirtyPathsResult.code !== 0) { + return { + error: `Unable to inspect git status before starting autoresearch: ${mergeStdoutStderr(dirtyPathsResult).trim() || `exit ${dirtyPathsResult.code}`}`, + ok: false, + }; + } + + const unsafeDirtyPaths = parseUnsafeDirtyPaths(dirtyPathsResult.stdout); + if (unsafeDirtyPaths.length > 0) { + const preview = unsafeDirtyPaths.slice(0, 5).join(", "); + const suffix = unsafeDirtyPaths.length > 5 ? ` (+${unsafeDirtyPaths.length - 5} more)` : ""; + return { + error: + "Autoresearch needs a clean git worktree before it can create an isolated branch. " + + `Commit or stash these paths first: ${preview}${suffix}`, + ok: false, + }; + } + + const branchName = await allocateBranchName(api, workDir, goal); + const checkoutResult = await api.exec("git", ["checkout", "-b", branchName], { cwd: workDir, timeout: 10_000 }); + if (checkoutResult.code !== 0) { + return { + error: + `Failed to create autoresearch branch ${branchName}: ` + + `${mergeStdoutStderr(checkoutResult).trim() || `exit ${checkoutResult.code}`}`, + ok: false, + }; + } + + return { + branchName, + created: true, + ok: true, + }; +} + +function parseUnsafeDirtyPaths(statusOutput: string): string[] { + const unsafePaths = new Set(); + for (const line of statusOutput.split("\n")) { + const trimmedLine = line.trimEnd(); + if (trimmedLine.length < 4) continue; + const rawPath = trimmedLine.slice(3).trim(); + if (rawPath.length === 0) continue; + const renameParts = rawPath.split(" -> "); + const normalizedPath = normalizeStatusPath(renameParts[renameParts.length - 1] ?? rawPath); + if (normalizedPath.length === 0) continue; + if (PROTECTED_AUTORESEARCH_FILES.some(path => path === normalizedPath)) continue; + unsafePaths.add(normalizedPath); + } + return [...unsafePaths]; +} + +function normalizeStatusPath(path: string): string { + let normalized = path.trim(); + if (normalized.startsWith('"') && normalized.endsWith('"')) { + normalized = normalized.slice(1, -1); + } + if (normalized.startsWith("./")) { + normalized = normalized.slice(2); + } + return normalized; +} + +async function allocateBranchName(api: ExtensionAPI, workDir: string, goal: string | null): Promise { + const baseName = `${AUTORESEARCH_BRANCH_PREFIX}${slugifyGoal(goal)}-${currentDateStamp()}`; + let candidate = baseName; + let suffix = 2; + while (await branchExists(api, workDir, candidate)) { + candidate = `${baseName}-${suffix}`; + suffix += 1; + } + return candidate; +} + +async function branchExists(api: ExtensionAPI, workDir: string, branchName: string): Promise { + const result = await api.exec("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], { + cwd: workDir, + timeout: 5_000, + }); + return result.code === 0; +} + +function slugifyGoal(goal: string | null): string { + const normalized = (goal ?? "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + const trimmed = normalized.slice(0, BRANCH_NAME_MAX_LENGTH).replace(/-+$/g, ""); + return trimmed || "session"; +} + +function currentDateStamp(): string { + const now = new Date(); + const year = String(now.getFullYear()); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + return `${year}${month}${day}`; +} + +function mergeStdoutStderr(result: { stderr: string; stdout: string }): string { + return `${result.stdout}${result.stderr}`; +} diff --git a/packages/coding-agent/src/autoresearch/helpers.ts b/packages/coding-agent/src/autoresearch/helpers.ts index d5b845ef7..681c50f41 100644 --- a/packages/coding-agent/src/autoresearch/helpers.ts +++ b/packages/coding-agent/src/autoresearch/helpers.ts @@ -9,6 +9,13 @@ export const METRIC_LINE_PREFIX = "METRIC"; export const ASI_LINE_PREFIX = "ASI"; export const EXPERIMENT_MAX_LINES = 10; export const EXPERIMENT_MAX_BYTES = 4 * 1024; +export const PROTECTED_AUTORESEARCH_FILES = [ + "autoresearch.jsonl", + "autoresearch.md", + "autoresearch.ideas.md", + "autoresearch.sh", + "autoresearch.checks.sh", +] as const; const DENIED_KEY_NAMES = new Set(["__proto__", "constructor", "prototype"]); diff --git a/packages/coding-agent/src/autoresearch/index.ts b/packages/coding-agent/src/autoresearch/index.ts index 8f13e5cdc..6de676653 100644 --- a/packages/coding-agent/src/autoresearch/index.ts +++ b/packages/coding-agent/src/autoresearch/index.ts @@ -6,6 +6,7 @@ import type { ExtensionContext, ExtensionFactory } from "../extensibility/extens import commandInitializeTemplate from "./command-initialize.md" with { type: "text" }; import commandResumeTemplate from "./command-resume.md" with { type: "text" }; import { createDashboardController } from "./dashboard"; +import { ensureAutoresearchBranch } from "./git"; import { readMaxExperiments, resolveWorkDir, validateWorkDir } from "./helpers"; import promptTemplate from "./prompt.md" with { type: "text" }; import resumeMessageTemplate from "./resume-message.md" with { type: "text" }; @@ -131,6 +132,12 @@ export const createAutoresearchExtension: ExtensionFactory = api => { const hasAutoresearchMd = fs.existsSync(autoresearchMdPath); if (hasAutoresearchMd) { + const branchResult = await ensureAutoresearchBranch(api, workDir, runtime.goal); + if (!branchResult.ok) { + ctx.ui.notify(branchResult.error, "error"); + return; + } + setMode(ctx, true, runtime.goal, "on"); runtime.experimentsThisSession = 0; runtime.autoResumeTurns = 0; @@ -139,6 +146,9 @@ export const createAutoresearchExtension: ExtensionFactory = api => { api.sendUserMessage( renderPromptTemplate(commandResumeTemplate, { autoresearch_md_path: autoresearchMdPath, + branch_status_line: branchResult.created + ? `Created and checked out dedicated git branch \`${branchResult.branchName}\` before resuming.` + : `Using dedicated git branch \`${branchResult.branchName}\`.`, }), ); return; @@ -156,12 +166,25 @@ export const createAutoresearchExtension: ExtensionFactory = api => { return; } + const branchResult = await ensureAutoresearchBranch(api, workDir, intent); + if (!branchResult.ok) { + ctx.ui.notify(branchResult.error, "error"); + return; + } + setMode(ctx, true, intent, "on"); runtime.experimentsThisSession = 0; runtime.autoResumeTurns = 0; dashboard.updateWidget(ctx, runtime); await api.setActiveTools([...new Set([...api.getActiveTools(), ...EXPERIMENT_TOOL_NAMES])]); - api.sendUserMessage(renderPromptTemplate(commandInitializeTemplate, { intent })); + api.sendUserMessage( + renderPromptTemplate(commandInitializeTemplate, { + branch_status_line: branchResult.created + ? `Created and checked out dedicated git branch \`${branchResult.branchName}\`.` + : `Using dedicated git branch \`${branchResult.branchName}\`.`, + intent, + }), + ); }, }); diff --git a/packages/coding-agent/src/autoresearch/prompt.md b/packages/coding-agent/src/autoresearch/prompt.md index 56ce3efd3..2ed34f189 100644 --- a/packages/coding-agent/src/autoresearch/prompt.md +++ b/packages/coding-agent/src/autoresearch/prompt.md @@ -25,7 +25,7 @@ You are running an autonomous experiment loop. Keep iterating until the user int - Identify the true bottleneck or quality constraint. - Check existing scripts, benchmark harnesses, and config files. 2. Keep your notes in `autoresearch.md`. - - Record the goal, the benchmark command, the primary metric, important secondary metrics, and the running ideas backlog. + - Record the goal, the benchmark command, the primary metric, important secondary metrics, the files in scope, hard constraints, and the running ideas backlog. - Update the notes whenever the strategy changes. 3. Use `autoresearch.sh` as the canonical benchmark entrypoint. - If it does not exist yet, create it. @@ -87,6 +87,15 @@ Suggested structure: - primary metric: - secondary metrics: +## Files in Scope +- path: + +## Off Limits +- path: + +## Constraints +- rule: + ## Baseline - metric: - notes: @@ -104,6 +113,7 @@ Suggested structure: - Do not game the benchmark. - Do not overfit to synthetic inputs if the real workload is broader. - Preserve correctness. +- Only modify files that are explicitly in scope for the current session. - If you create `autoresearch.checks.sh`, treat it as a hard gate for `keep`. - If the user sends another message while a run is in progress, finish the current run and logging cycle first, then address the new input in the next iteration. diff --git a/packages/coding-agent/src/autoresearch/resume-message.md b/packages/coding-agent/src/autoresearch/resume-message.md index 55fda95ad..62c10b26a 100644 --- a/packages/coding-agent/src/autoresearch/resume-message.md +++ b/packages/coding-agent/src/autoresearch/resume-message.md @@ -1,6 +1,7 @@ The autoresearch loop ended unexpectedly. Resume it now. - Read `autoresearch.md` and `autoresearch.jsonl`. +- Treat `autoresearch.md` as the source of truth for the current direction, scope, and constraints. - Inspect recent git history for context. - Continue from the most promising unfinished direction. {{#if has_ideas}} diff --git a/packages/coding-agent/src/autoresearch/tools/log-experiment.ts b/packages/coding-agent/src/autoresearch/tools/log-experiment.ts index e0dfa0e53..2a7420a46 100644 --- a/packages/coding-agent/src/autoresearch/tools/log-experiment.ts +++ b/packages/coding-agent/src/autoresearch/tools/log-experiment.ts @@ -5,7 +5,14 @@ import { Text } from "@oh-my-pi/pi-tui"; import { Type } from "@sinclair/typebox"; import type { ToolDefinition } from "../../extensibility/extensions"; import type { Theme } from "../../modes/theme/theme"; -import { formatNum, inferMetricUnitFromName, mergeAsi, resolveWorkDir, validateWorkDir } from "../helpers"; +import { + formatNum, + inferMetricUnitFromName, + mergeAsi, + PROTECTED_AUTORESEARCH_FILES, + resolveWorkDir, + validateWorkDir, +} from "../helpers"; import { cloneExperimentState, computeConfidence, @@ -52,14 +59,6 @@ const logExperimentSchema = Type.Object({ ), }); -const PROTECTED_AUTORESEARCH_FILES = [ - "autoresearch.jsonl", - "autoresearch.md", - "autoresearch.ideas.md", - "autoresearch.sh", - "autoresearch.checks.sh", -] as const; - interface PreservedFile { content: Buffer; path: string; @@ -107,6 +106,12 @@ export function createLogExperimentTool( } const mergedAsi = mergeAsi(runtime.lastRunAsi, sanitizeAsi(params.asi)); + const asiValidationError = validateAsiRequirements(mergedAsi, params.status); + if (asiValidationError) { + return { + content: [{ type: "text", text: `Error: ${asiValidationError}` }], + }; + } const experiment: ExperimentResult = { commit: params.commit.slice(0, 7), metric: params.metric, @@ -220,6 +225,23 @@ function sanitizeAsiValue(value: unknown): ASIData[string] | undefined { return undefined; } +export function validateAsiRequirements(asi: ASIData | undefined, status: ExperimentResult["status"]): string | null { + if (!asi) { + return "asi is required. Include at minimum a non-empty hypothesis."; + } + if (typeof asi.hypothesis !== "string" || asi.hypothesis.trim().length === 0) { + return "asi.hypothesis is required and must be a non-empty string."; + } + if (status === "keep") return null; + if (typeof asi.rollback_reason !== "string" || asi.rollback_reason.trim().length === 0) { + return "asi.rollback_reason is required for discard, crash, and checks_failed results."; + } + if (typeof asi.next_action_hint !== "string" || asi.next_action_hint.trim().length === 0) { + return "asi.next_action_hint is required for discard, crash, and checks_failed results."; + } + return null; +} + function validateSecondaryMetrics(state: ExperimentState, metrics: NumericMetricMap, force: boolean): string | null { if (state.secondaryMetrics.length === 0) return null; const knownNames = new Set(state.secondaryMetrics.map(metric => metric.name)); diff --git a/packages/coding-agent/src/modes/components/agent-dashboard.ts b/packages/coding-agent/src/modes/components/agent-dashboard.ts index cd5f46d32..ade3e3119 100644 --- a/packages/coding-agent/src/modes/components/agent-dashboard.ts +++ b/packages/coding-agent/src/modes/components/agent-dashboard.ts @@ -50,6 +50,7 @@ import { discoverAgents } from "../../task/discovery"; import type { AgentDefinition, AgentSource } from "../../task/types"; import { shortenPath } from "../../tools/render-utils"; import { theme } from "../theme/theme"; +import { matchesAppInterrupt } from "../utils/keybinding-matchers"; import { DynamicBorder } from "./dynamic-border"; type SourceTabId = "all" | AgentSource; @@ -993,7 +994,7 @@ export class AgentDashboard extends Container { } if (this.#createSpec) { - if (matchesKey(data, "escape") || matchesKey(data, "esc")) { + if (matchesAppInterrupt(data)) { this.#clearCreateFlow(); this.#buildLayout(); return; @@ -1017,7 +1018,7 @@ export class AgentDashboard extends Container { } if (this.#createInput || this.#createGenerating) { - if (matchesKey(data, "escape") || matchesKey(data, "esc")) { + if (matchesAppInterrupt(data)) { if (!this.#createGenerating) { this.#clearCreateFlow(); this.#buildLayout(); @@ -1037,7 +1038,7 @@ export class AgentDashboard extends Container { } if (this.#editInput) { - if (matchesKey(data, "escape") || matchesKey(data, "esc")) { + if (matchesAppInterrupt(data)) { this.#cancelModelEdit(); return; } @@ -1048,7 +1049,7 @@ export class AgentDashboard extends Container { return; } - if (matchesKey(data, "escape") || matchesKey(data, "esc")) { + if (matchesAppInterrupt(data)) { if (this.#searchQuery.length > 0) { this.#searchQuery = ""; this.#applyFilters(); diff --git a/packages/coding-agent/src/modes/components/extensions/extension-dashboard.ts b/packages/coding-agent/src/modes/components/extensions/extension-dashboard.ts index 26b3d7217..890611a9f 100644 --- a/packages/coding-agent/src/modes/components/extensions/extension-dashboard.ts +++ b/packages/coding-agent/src/modes/components/extensions/extension-dashboard.ts @@ -24,6 +24,7 @@ import { import { Settings } from "../../../config/settings"; import { DynamicBorder } from "../../../modes/components/dynamic-border"; import { theme } from "../../../modes/theme/theme"; +import { matchesAppInterrupt } from "../../../modes/utils/keybinding-matchers"; import { ExtensionList } from "./extension-list"; import { InspectorPanel } from "./inspector-panel"; import { applyFilter, createInitialState, filterByProvider, refreshState, toggleProvider } from "./state-manager"; @@ -251,7 +252,7 @@ export class ExtensionDashboard extends Container { } // Escape - clear search first, then close - if (matchesKey(data, "escape") || matchesKey(data, "esc")) { + if (matchesAppInterrupt(data)) { if (this.#state.searchQuery.length > 0) { this.#state.searchQuery = ""; this.#state.searchFiltered = this.#state.tabFiltered; diff --git a/packages/coding-agent/src/modes/components/history-search.ts b/packages/coding-agent/src/modes/components/history-search.ts index 34d741a2c..e3814eef5 100644 --- a/packages/coding-agent/src/modes/components/history-search.ts +++ b/packages/coding-agent/src/modes/components/history-search.ts @@ -11,6 +11,7 @@ import { visibleWidth, } from "@oh-my-pi/pi-tui"; import { theme } from "../../modes/theme/theme"; +import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers"; import type { HistoryEntry, HistoryStorage } from "../../session/history-storage"; import { DynamicBorder } from "./dynamic-border"; @@ -137,7 +138,7 @@ export class HistorySearchComponent extends Container { return; } - if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) { + if (matchesAppInterrupt(keyData)) { this.#onCancel(); return; } diff --git a/packages/coding-agent/src/modes/components/hook-editor.ts b/packages/coding-agent/src/modes/components/hook-editor.ts index 56d9fce29..94ddfd62b 100644 --- a/packages/coding-agent/src/modes/components/hook-editor.ts +++ b/packages/coding-agent/src/modes/components/hook-editor.ts @@ -4,6 +4,7 @@ */ import { Container, Editor, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; import { getEditorTheme, theme } from "../../modes/theme/theme"; +import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers"; import { getEditorCommand, openInEditor } from "../../utils/external-editor"; import { DynamicBorder } from "./dynamic-border"; @@ -67,7 +68,7 @@ export class HookEditorComponent extends Container { } // Escape to cancel - if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) { + if (matchesAppInterrupt(keyData)) { this.#onCancelCallback(); return; } diff --git a/packages/coding-agent/src/modes/components/hook-input.ts b/packages/coding-agent/src/modes/components/hook-input.ts index eb4d01f66..7a42ecde1 100644 --- a/packages/coding-agent/src/modes/components/hook-input.ts +++ b/packages/coding-agent/src/modes/components/hook-input.ts @@ -3,6 +3,7 @@ */ import { Container, Input, Markdown, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; import { getMarkdownTheme, theme } from "../../modes/theme/theme"; +import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers"; import { CountdownTimer } from "./countdown-timer"; import { DynamicBorder } from "./dynamic-border"; @@ -65,7 +66,7 @@ export class HookInputComponent extends Container { this.#countdown?.reset(); if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") { this.#onSubmitCallback(this.#input.getValue()); - } else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) { + } else if (matchesAppInterrupt(keyData)) { this.#onCancelCallback(); } else { this.#input.handleInput(keyData); diff --git a/packages/coding-agent/src/modes/components/hook-selector.ts b/packages/coding-agent/src/modes/components/hook-selector.ts index c00aea1d1..d39e973b7 100644 --- a/packages/coding-agent/src/modes/components/hook-selector.ts +++ b/packages/coding-agent/src/modes/components/hook-selector.ts @@ -16,6 +16,7 @@ import { visibleWidth, } from "@oh-my-pi/pi-tui"; import { getMarkdownTheme, theme } from "../../modes/theme/theme"; +import { matchesSelectCancel } from "../../modes/utils/keybinding-matchers"; import { CountdownTimer } from "./countdown-timer"; import { DynamicBorder } from "./dynamic-border"; @@ -173,7 +174,7 @@ export class HookSelectorComponent extends Container { this.#onLeftCallback?.(); } else if (matchesKey(keyData, "right")) { this.#onRightCallback?.(); - } else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesKey(keyData, "ctrl+c")) { + } else if (matchesSelectCancel(keyData)) { this.#onCancelCallback(); } } diff --git a/packages/coding-agent/src/modes/components/mcp-add-wizard.ts b/packages/coding-agent/src/modes/components/mcp-add-wizard.ts index 1e6fc22a2..88a3dc199 100644 --- a/packages/coding-agent/src/modes/components/mcp-add-wizard.ts +++ b/packages/coding-agent/src/modes/components/mcp-add-wizard.ts @@ -19,6 +19,7 @@ import { analyzeAuthError, discoverOAuthEndpoints } from "../../mcp/oauth-discov import type { MCPHttpServerConfig, MCPServerConfig, MCPSseServerConfig, MCPStdioServerConfig } from "../../mcp/types"; import { shortenPath } from "../../tools/render-utils"; import { theme } from "../theme/theme"; +import { matchesAppInterrupt } from "../utils/keybinding-matchers"; import { DynamicBorder } from "./dynamic-border"; type TransportType = "stdio" | "http" | "sse"; @@ -452,7 +453,7 @@ export class MCPAddWizard extends Container { } // Handle Escape (always handled by wizard) - if (matchesKey(keyData, "escape")) { + if (matchesAppInterrupt(keyData)) { if (this.#currentStep === "name") { // Cancel wizard this.#onCancelCallback(); diff --git a/packages/coding-agent/src/modes/components/model-selector.ts b/packages/coding-agent/src/modes/components/model-selector.ts index 75e6c8e44..2108ce48d 100644 --- a/packages/coding-agent/src/modes/components/model-selector.ts +++ b/packages/coding-agent/src/modes/components/model-selector.ts @@ -1,6 +1,17 @@ import { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import { getSupportedEfforts, type Model, modelsAreEqual } from "@oh-my-pi/pi-ai"; -import { Container, Input, matchesKey, Spacer, type Tab, TabBar, Text, type TUI, visibleWidth } from "@oh-my-pi/pi-tui"; +import { + Container, + getKeybindings, + Input, + matchesKey, + Spacer, + type Tab, + TabBar, + Text, + type TUI, + visibleWidth, +} from "@oh-my-pi/pi-tui"; import { MODEL_ROLE_IDS, MODEL_ROLES, type ModelRegistry, type ModelRole } from "../../config/model-registry"; import { resolveModelRoleValue } from "../../config/model-resolver"; import type { Settings } from "../../config/settings"; @@ -647,7 +658,7 @@ export class ModelSelectorComponent extends Container { } // Escape or Ctrl+C - close selector - if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesKey(keyData, "ctrl+c")) { + if (getKeybindings().matches(keyData, "tui.select.cancel")) { this.#onCancelCallback(); return; } @@ -698,7 +709,7 @@ export class ModelSelectorComponent extends Container { return; } - if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesKey(keyData, "ctrl+c")) { + if (getKeybindings().matches(keyData, "tui.select.cancel")) { if (this.#menuStep === "thinking" && this.#menuSelectedRole !== null) { this.#menuStep = "role"; const roleIndex = MENU_ROLE_ACTIONS.findIndex(action => action.role === this.#menuSelectedRole); diff --git a/packages/coding-agent/src/modes/components/oauth-selector.ts b/packages/coding-agent/src/modes/components/oauth-selector.ts index 6602805a9..edd6eb49a 100644 --- a/packages/coding-agent/src/modes/components/oauth-selector.ts +++ b/packages/coding-agent/src/modes/components/oauth-selector.ts @@ -1,6 +1,7 @@ import { getOAuthProviders, type OAuthProviderInfo } from "@oh-my-pi/pi-ai"; import { Container, matchesKey, Spacer, TruncatedText } from "@oh-my-pi/pi-tui"; import { theme } from "../../modes/theme/theme"; +import { matchesSelectCancel } from "../../modes/utils/keybinding-matchers"; import type { AuthStorage } from "../../session/auth-storage"; import { DynamicBorder } from "./dynamic-border"; /** @@ -202,7 +203,7 @@ export class OAuthSelectorComponent extends Container { } } // Escape or Ctrl+C - else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesKey(keyData, "ctrl+c")) { + else if (matchesSelectCancel(keyData)) { this.stopValidation(); this.#onCancelCallback(); } diff --git a/packages/coding-agent/src/modes/components/session-selector.ts b/packages/coding-agent/src/modes/components/session-selector.ts index 2739cc0ee..8087fcb7c 100644 --- a/packages/coding-agent/src/modes/components/session-selector.ts +++ b/packages/coding-agent/src/modes/components/session-selector.ts @@ -11,6 +11,7 @@ import { visibleWidth, } from "@oh-my-pi/pi-tui"; import { theme } from "../../modes/theme/theme"; +import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers"; import type { SessionInfo } from "../../session/session-manager"; import { fuzzyFilter } from "../../utils/fuzzy"; import { DynamicBorder } from "./dynamic-border"; @@ -219,7 +220,7 @@ class SessionList implements Component { return; } // Escape - cancel - if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) { + if (matchesAppInterrupt(keyData)) { if (this.onCancel) { this.onCancel(); } diff --git a/packages/coding-agent/src/modes/components/settings-selector.ts b/packages/coding-agent/src/modes/components/settings-selector.ts index 930bcefda..f6cc76ea7 100644 --- a/packages/coding-agent/src/modes/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/components/settings-selector.ts @@ -21,6 +21,7 @@ import type { } from "../../config/settings-schema"; import { SETTING_TABS, TAB_METADATA } from "../../config/settings-schema"; import { getCurrentThemeName, getSelectListTheme, getSettingsListTheme, theme } from "../../modes/theme/theme"; +import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers"; import { getTabBarTheme } from "../shared"; import { DynamicBorder } from "./dynamic-border"; import { PluginSettingsComponent } from "./plugin-settings"; @@ -521,7 +522,7 @@ export class SettingsSelectorComponent extends Container { } // Escape at top level cancels - if ((matchesKey(data, "escape") || matchesKey(data, "esc")) && !this.#currentSubmenu) { + if (matchesAppInterrupt(data) && !this.#currentSubmenu) { this.callbacks.onCancel(); return; } diff --git a/packages/coding-agent/src/modes/components/status-line-segment-editor.ts b/packages/coding-agent/src/modes/components/status-line-segment-editor.ts index c2f7dcf0d..f30050c8f 100644 --- a/packages/coding-agent/src/modes/components/status-line-segment-editor.ts +++ b/packages/coding-agent/src/modes/components/status-line-segment-editor.ts @@ -11,6 +11,7 @@ import { Container, matchesKey, padding } from "@oh-my-pi/pi-tui"; import type { StatusLineSegmentId } from "../../config/settings-schema"; import { theme } from "../../modes/theme/theme"; +import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers"; import { ALL_SEGMENT_IDS } from "./status-line/segments"; // Segment display names and short descriptions @@ -239,7 +240,7 @@ export class StatusLineSegmentEditorComponent extends Container { const left = this.#getSegmentsForColumn("left").map(s => s.id); const right = this.#getSegmentsForColumn("right").map(s => s.id); this.callbacks.onSave(left, right); - } else if (matchesKey(data, "escape") || matchesKey(data, "esc")) { + } else if (matchesAppInterrupt(data)) { this.callbacks.onCancel(); } } diff --git a/packages/coding-agent/src/modes/components/tree-selector.ts b/packages/coding-agent/src/modes/components/tree-selector.ts index a2491bc3a..19649ecb4 100644 --- a/packages/coding-agent/src/modes/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/components/tree-selector.ts @@ -12,6 +12,7 @@ import { } from "@oh-my-pi/pi-tui"; import type { TreeFilterMode } from "../../config/settings-schema"; import { theme } from "../../modes/theme/theme"; +import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers"; import type { SessionTreeNode } from "../../session/session-manager"; import { shortenPath } from "../../tools/render-utils"; import { DynamicBorder } from "./dynamic-border"; @@ -702,7 +703,7 @@ class TreeList implements Component { if (selected && this.onSelect) { this.onSelect(selected.node.entry.id); } - } else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) { + } else if (matchesAppInterrupt(keyData)) { if (this.#searchQuery) { this.#searchQuery = ""; this.#applyFilter(); @@ -807,7 +808,7 @@ class LabelInput implements Component { if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") { const value = this.#input.getValue().trim(); this.onSubmit?.(this.entryId, value || undefined); - } else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) { + } else if (matchesAppInterrupt(keyData)) { this.onCancel?.(); } else { this.#input.handleInput(keyData); diff --git a/packages/coding-agent/src/modes/components/user-message-selector.ts b/packages/coding-agent/src/modes/components/user-message-selector.ts index 7b6dffff9..2d5066208 100644 --- a/packages/coding-agent/src/modes/components/user-message-selector.ts +++ b/packages/coding-agent/src/modes/components/user-message-selector.ts @@ -1,5 +1,6 @@ import { type Component, Container, matchesKey, Spacer, Text, truncateToWidth } from "@oh-my-pi/pi-tui"; import { theme } from "../../modes/theme/theme"; +import { matchesSelectCancel } from "../../modes/utils/keybinding-matchers"; import { DynamicBorder } from "./dynamic-border"; interface UserMessageItem { @@ -91,14 +92,8 @@ class UserMessageList implements Component { this.onSelect(selected.id); } } - // Escape - cancel - else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) { - if (this.onCancel) { - this.onCancel(); - } - } - // Ctrl+C - cancel - else if (matchesKey(keyData, "ctrl+c")) { + // Escape / cancel + else if (matchesSelectCancel(keyData)) { if (this.onCancel) { this.onCancel(); } diff --git a/packages/coding-agent/src/modes/utils/keybinding-matchers.ts b/packages/coding-agent/src/modes/utils/keybinding-matchers.ts new file mode 100644 index 000000000..7fc7383f6 --- /dev/null +++ b/packages/coding-agent/src/modes/utils/keybinding-matchers.ts @@ -0,0 +1,21 @@ +import { getKeybindings, matchesKey } from "@oh-my-pi/pi-tui"; + +/** + * Match the coding-agent interrupt key. + * + * Interactive mode installs a keybinding manager that exposes `app.interrupt` + * globally, but some isolated component tests still run with only TUI + * keybindings registered. In that case, fall back to raw Escape matching. + */ +export function matchesAppInterrupt(data: string): boolean { + const keybindings = getKeybindings(); + const interruptKeys = keybindings.getKeys("app.interrupt"); + if (interruptKeys.length > 0) { + return keybindings.matches(data, "app.interrupt"); + } + return matchesKey(data, "escape") || matchesKey(data, "esc"); +} + +export function matchesSelectCancel(data: string): boolean { + return getKeybindings().matches(data, "tui.select.cancel"); +} diff --git a/packages/coding-agent/test/autoresearch-state.test.ts b/packages/coding-agent/test/autoresearch-state.test.ts index c97be3d11..285cd70da 100644 --- a/packages/coding-agent/test/autoresearch-state.test.ts +++ b/packages/coding-agent/test/autoresearch-state.test.ts @@ -6,6 +6,7 @@ import { Snowflake } from "@oh-my-pi/pi-utils"; import { isAutoresearchShCommand } from "../src/autoresearch/helpers"; import { createAutoresearchExtension } from "../src/autoresearch/index"; import { reconstructStateFromJsonl } from "../src/autoresearch/state"; +import { validateAsiRequirements } from "../src/autoresearch/tools/log-experiment"; import type { ExtensionAPI, ExtensionCommandContext, @@ -118,12 +119,18 @@ describe("autoresearch command guard", () => { interface AutoresearchCommandHarness { command: RegisteredCommand; ctx: ExtensionCommandContext; + execCalls: Array<{ args: string[]; command: string }>; sentMessages: string[]; inputCalls: Array<{ title: string; placeholder: string | undefined }>; notifications: Array<{ message: string; type: "info" | "warning" | "error" | undefined }>; } -function createAutoresearchCommandHarness(cwd: string, inputResult: string | undefined): AutoresearchCommandHarness { +function createAutoresearchCommandHarness( + cwd: string, + inputResult: string | undefined, + execImpl?: (command: string, args: string[]) => Promise<{ code: number; stderr: string; stdout: string }>, +): AutoresearchCommandHarness { + const execCalls: Array<{ args: string[]; command: string }> = []; const sentMessages: string[] = []; const inputCalls: Array<{ title: string; placeholder: string | undefined }> = []; const notifications: Array<{ message: string; type: "info" | "warning" | "error" | undefined }> = []; @@ -131,6 +138,13 @@ function createAutoresearchCommandHarness(cwd: string, inputResult: string | und const api = { appendEntry(_customType: string, _data?: unknown): void {}, + exec: async (commandName: string, args: string[]) => { + execCalls.push({ args: [...args], command: commandName }); + if (execImpl) { + return execImpl(commandName, args); + } + return { code: 0, stderr: "", stdout: "" }; + }, on(): void {}, registerCommand(name: string, options: Omit): void { command = { name, ...options }; @@ -191,7 +205,7 @@ function createAutoresearchCommandHarness(cwd: string, inputResult: string | und waitForIdle: async () => {}, } as unknown as ExtensionCommandContext; - return { command, ctx, sentMessages, inputCalls, notifications }; + return { command, ctx, execCalls, sentMessages, inputCalls, notifications }; } interface AutoresearchLifecycleHarness { @@ -289,7 +303,30 @@ describe("autoresearch command startup", () => { it("asks for intent and sends an initialization prompt when no autoresearch.md exists", async () => { const dir = makeTempDir(); tempDirs.push(dir); - const harness = createAutoresearchCommandHarness(dir, "reduce edit benchmark runtime variance"); + let currentBranch = "main"; + const branches = new Set(); + const harness = createAutoresearchCommandHarness( + dir, + "reduce edit benchmark runtime variance", + async (command, args) => { + if (command !== "git") return { code: 1, stderr: "unexpected command", stdout: "" }; + if (args[0] === "rev-parse") return { code: 0, stderr: "", stdout: `${dir}\n` }; + if (args[0] === "branch" && args[1] === "--show-current") { + return { code: 0, stderr: "", stdout: `${currentBranch}\n` }; + } + if (args[0] === "status") return { code: 0, stderr: "", stdout: "" }; + if (args[0] === "show-ref") { + const branchName = args[args.length - 1]?.replace("refs/heads/", "") ?? ""; + return { code: branches.has(branchName) ? 0 : 1, stderr: "", stdout: "" }; + } + if (args[0] === "checkout" && args[1] === "-b") { + currentBranch = args[2] ?? currentBranch; + branches.add(currentBranch); + return { code: 0, stderr: "", stdout: "" }; + } + return { code: 1, stderr: `unexpected git args: ${args.join(" ")}`, stdout: "" }; + }, + ); await harness.command.handler("", harness.ctx); @@ -299,8 +336,12 @@ describe("autoresearch command startup", () => { expect(harness.sentMessages).toHaveLength(1); expect(harness.sentMessages[0]).toContain("Set up autoresearch for this intent:"); expect(harness.sentMessages[0]).toContain("reduce edit benchmark runtime variance"); + expect(harness.sentMessages[0]).toContain("Created and checked out dedicated git branch"); expect(harness.sentMessages[0]).toContain("Explain briefly what autoresearch will do in this repository"); + expect(harness.sentMessages[0]).toContain("Files in Scope"); expect(harness.notifications).toEqual([]); + const checkoutCall = harness.execCalls.find(call => call.command === "git" && call.args[0] === "checkout"); + expect(checkoutCall?.args[2]).toMatch(/^autoresearch\/reduce-edit-benchmark-runtime-variance-\d{8}$/); }); it("resumes from autoresearch.md without asking for intent when notes already exist", async () => { @@ -308,7 +349,14 @@ describe("autoresearch command startup", () => { tempDirs.push(dir); const autoresearchMdPath = path.join(dir, "autoresearch.md"); fs.writeFileSync(autoresearchMdPath, "# Autoresearch\n\nExisting notes\n"); - const harness = createAutoresearchCommandHarness(dir, "ignored"); + const harness = createAutoresearchCommandHarness(dir, "ignored", async (command, args) => { + if (command !== "git") return { code: 1, stderr: "unexpected command", stdout: "" }; + if (args[0] === "rev-parse") return { code: 0, stderr: "", stdout: `${dir}\n` }; + if (args[0] === "branch" && args[1] === "--show-current") { + return { code: 0, stderr: "", stdout: "autoresearch/existing-20260322\n" }; + } + return { code: 1, stderr: `unexpected git args: ${args.join(" ")}`, stdout: "" }; + }); await harness.command.handler("", harness.ctx); @@ -319,7 +367,9 @@ describe("autoresearch command startup", () => { "", `@${autoresearchMdPath}`, "", - "Use the notes as the source of truth for the current direction.", + "Using dedicated git branch `autoresearch/existing-20260322`.", + "", + "Use the notes as the source of truth for the current direction, scope, and constraints.", "- inspect recent git history for context", "- inspect `autoresearch.jsonl` if it exists", "- continue the most promising unfinished branch", @@ -338,6 +388,37 @@ describe("autoresearch command startup", () => { expect(harness.sentMessages).toEqual([]); expect(harness.notifications).toEqual([{ message: "Autoresearch intent is required", type: "info" }]); }); + + it("refuses to start when non-autoresearch files are dirty on a non-autoresearch branch", async () => { + const dir = makeTempDir(); + tempDirs.push(dir); + const harness = createAutoresearchCommandHarness( + dir, + "reduce edit benchmark runtime variance", + async (command, args) => { + if (command !== "git") return { code: 1, stderr: "unexpected command", stdout: "" }; + if (args[0] === "rev-parse") return { code: 0, stderr: "", stdout: `${dir}\n` }; + if (args[0] === "branch" && args[1] === "--show-current") { + return { code: 0, stderr: "", stdout: "main\n" }; + } + if (args[0] === "status") { + return { code: 0, stderr: "", stdout: " M packages/coding-agent/src/sdk.ts\n" }; + } + return { code: 1, stderr: `unexpected git args: ${args.join(" ")}`, stdout: "" }; + }, + ); + + await harness.command.handler("", harness.ctx); + + expect(harness.sentMessages).toEqual([]); + expect(harness.notifications).toEqual([ + { + message: + "Autoresearch needs a clean git worktree before it can create an isolated branch. Commit or stash these paths first: packages/coding-agent/src/sdk.ts", + type: "error", + }, + ]); + }); }); describe("autoresearch lifecycle tool activation", () => { @@ -369,3 +450,34 @@ describe("autoresearch lifecycle tool activation", () => { expect(harness.setActiveToolsCalls).toEqual([["read"]]); }); }); + +describe("autoresearch ASI requirements", () => { + it("requires a hypothesis for every run", () => { + expect(validateAsiRequirements(undefined, "keep")).toBe( + "asi is required. Include at minimum a non-empty hypothesis.", + ); + expect(validateAsiRequirements({}, "keep")).toBe("asi.hypothesis is required and must be a non-empty string."); + }); + + it("requires rollback metadata for failed runs", () => { + expect(validateAsiRequirements({ hypothesis: "try a smaller cache" }, "discard")).toBe( + "asi.rollback_reason is required for discard, crash, and checks_failed results.", + ); + expect( + validateAsiRequirements( + { hypothesis: "try a smaller cache", rollback_reason: "metric regressed" }, + "checks_failed", + ), + ).toBe("asi.next_action_hint is required for discard, crash, and checks_failed results."); + expect( + validateAsiRequirements( + { + hypothesis: "try a smaller cache", + next_action_hint: "re-run with lower batch size", + rollback_reason: "metric regressed", + }, + "crash", + ), + ).toBeNull(); + }); +}); diff --git a/packages/coding-agent/test/keybindings-escape-components.test.ts b/packages/coding-agent/test/keybindings-escape-components.test.ts new file mode 100644 index 000000000..515091473 --- /dev/null +++ b/packages/coding-agent/test/keybindings-escape-components.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; +import { getBundledModel } from "@oh-my-pi/pi-ai"; +import { KeybindingsManager } from "@oh-my-pi/pi-coding-agent/config/keybindings"; +import type { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { ModelSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/model-selector"; +import { SessionSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/session-selector"; +import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import type { SessionInfo } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { setKeybindings, type TUI } from "@oh-my-pi/pi-tui"; + +beforeAll(() => { + initTheme(); +}); + +afterEach(() => { + setKeybindings(KeybindingsManager.inMemory()); + vi.restoreAllMocks(); +}); + +function createSession(id: string, title: string): SessionInfo { + return { + path: `/tmp/${id}.jsonl`, + id, + cwd: "/tmp", + title, + created: new Date("2024-01-01T00:00:00Z"), + modified: new Date("2024-01-02T00:00:00Z"), + messageCount: 1, + firstMessage: `${title} first message`, + allMessagesText: `${title} first message`, + }; +} + +describe("component escape bindings", () => { + it("uses app.interrupt for session selector cancel without changing Ctrl+C exit", () => { + const keybindings = KeybindingsManager.inMemory({ + "app.interrupt": "alt+x", + }); + setKeybindings(keybindings); + + const onCancel = vi.fn(); + const onExit = vi.fn(); + const selector = new SessionSelectorComponent( + [createSession("session-a", "Alpha"), createSession("session-b", "Beta")], + () => {}, + onCancel, + onExit, + ); + + selector.handleInput("\x1b"); + expect(onCancel).not.toHaveBeenCalled(); + + selector.handleInput("\x1bx"); + expect(onCancel).toHaveBeenCalledTimes(1); + + selector.handleInput("\x03"); + expect(onExit).toHaveBeenCalledTimes(1); + }); + + it("uses tui.select.cancel for model selector cancellation", async () => { + const keybindings = KeybindingsManager.inMemory({ + "tui.select.cancel": "ctrl+g", + }); + setKeybindings(keybindings); + + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) { + throw new Error("Expected bundled model anthropic/claude-sonnet-4-5"); + } + + const settings = Settings.isolated({ + modelRoles: { + default: `${model.provider}/${model.id}`, + }, + }); + const modelRegistry = { + getAll: () => [model], + getDiscoverableProviders: () => [], + } as unknown as ModelRegistry; + const ui = { + requestRender: vi.fn(), + } as unknown as TUI; + const onCancel = vi.fn(); + + const selector = new ModelSelectorComponent( + ui, + model, + settings, + modelRegistry, + [{ model, thinkingLevel: "off" }], + () => {}, + onCancel, + ); + + await Bun.sleep(0); + + selector.handleInput("\x1b"); + expect(onCancel).not.toHaveBeenCalled(); + + selector.handleInput("\x07"); + expect(onCancel).toHaveBeenCalledTimes(1); + }); +});