feat(coding-agent): added git isolation and keybinding utilities for autoresearch

- Added git branch isolation for autoresearch sessions with automatic branch creation, reuse, and worktree safety checks.
- Added scope definition sections (Files in Scope, Off Limits, Constraints) to autoresearch template for explicit session boundaries.
- Added keybinding matcher utilities for consistent escape/cancel key handling across interactive components.
- Added ASI metadata validation (hypothesis and rollback context) in log_experiment tool for experiment tracking.
- Refactored keybinding logic across 13 components to use centralized matcher functions instead of inline key checks.
This commit is contained in:
can1357
2026-03-22 21:47:48 +01:00
parent 012e0c90b9
commit cac628ccce
26 changed files with 535 additions and 44 deletions
+19
View File
@@ -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
@@ -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`
@@ -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
@@ -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<EnsureAutoresearchBranchResult> {
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<string>();
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<string> {
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<boolean> {
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}`;
}
@@ -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"]);
@@ -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,
}),
);
},
});
@@ -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.
@@ -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}}
@@ -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));
@@ -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();
@@ -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;
@@ -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;
}
@@ -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;
}
@@ -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);
@@ -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();
}
}
@@ -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();
@@ -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);
@@ -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();
}
@@ -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();
}
@@ -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;
}
@@ -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();
}
}
@@ -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);
@@ -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();
}
@@ -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");
}
@@ -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<RegisteredCommand, "name">): 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<string>();
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();
});
});
@@ -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);
});
});