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