Merge branch 'main' into feat/custom-thinking-budgets

This commit is contained in:
Melih Mucuk 2026-01-08 00:39:11 +03:00
commit d311978dfd
41 changed files with 1664 additions and 538 deletions

View file

@ -1262,8 +1262,24 @@ export class AgentSession {
const contextWindow = this.model?.contextWindow ?? 0;
// Skip overflow check if the message came from a different model.
// This handles the case where user switched from a smaller-context model (e.g. opus)
// to a larger-context model (e.g. codex) - the overflow error from the old model
// shouldn't trigger compaction for the new model.
const sameModel =
this.model && assistantMessage.provider === this.model.provider && assistantMessage.model === this.model.id;
// Skip overflow check if the error is from before a compaction in the current path.
// This handles the case where an error was kept after compaction (in the "kept" region).
// The error shouldn't trigger another compaction since we already compacted.
// Example: opus fails → switch to codex → compact → switch back to opus → opus error
// is still in context but shouldn't trigger compaction again.
const compactionEntry = this.sessionManager.getBranch().find((e) => e.type === "compaction");
const errorIsFromBeforeCompaction =
compactionEntry && assistantMessage.timestamp < new Date(compactionEntry.timestamp).getTime();
// Case 1: Overflow - LLM returned context overflow error
if (isContextOverflow(assistantMessage, contextWindow)) {
if (sameModel && !errorIsFromBeforeCompaction && isContextOverflow(assistantMessage, contextWindow)) {
// Remove the error message from agent state (it IS saved to session for history,
// but we don't want it in context for the retry)
const messages = this.agent.state.messages;

View file

@ -11,6 +11,8 @@ export type {
// Re-exports
AgentToolResult,
AgentToolUpdateCallback,
// App keybindings (for custom editors)
AppAction,
AppendEntryHandler,
BashToolResultEvent,
BeforeAgentStartEvent,
@ -42,6 +44,7 @@ export type {
GetAllToolsHandler,
GetThinkingLevelHandler,
GrepToolResultEvent,
KeybindingsManager,
LoadExtensionsResult,
// Loaded Extension
LoadedExtension,

View file

@ -99,6 +99,7 @@ function createNoOpUIContext(): ExtensionUIContext {
setEditorText: () => {},
getEditorText: () => "",
editor: async () => undefined,
setEditorComponent: () => {},
get theme() {
return theme;
},

View file

@ -74,6 +74,7 @@ const noOpUIContext: ExtensionUIContext = {
setEditorText: () => {},
getEditorText: () => "",
editor: async () => undefined,
setEditorComponent: () => {},
get theme() {
return theme;
},

View file

@ -15,12 +15,13 @@ import type {
ThinkingLevel,
} from "@mariozechner/pi-agent-core";
import type { ImageContent, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai";
import type { Component, KeyId, TUI } from "@mariozechner/pi-tui";
import type { Component, EditorComponent, EditorTheme, KeyId, TUI } from "@mariozechner/pi-tui";
import type { Static, TSchema } from "@sinclair/typebox";
import type { Theme } from "../../modes/interactive/theme/theme.js";
import type { CompactionPreparation, CompactionResult } from "../compaction/index.js";
import type { EventBus } from "../event-bus.js";
import type { ExecOptions, ExecResult } from "../exec.js";
import type { AppAction, KeybindingsManager } from "../keybindings.js";
import type { CustomMessage } from "../messages.js";
import type { ModelRegistry } from "../model-registry.js";
import type {
@ -41,6 +42,7 @@ import type {
export type { ExecOptions, ExecResult } from "../exec.js";
export type { AgentToolResult, AgentToolUpdateCallback };
export type { AppAction, KeybindingsManager } from "../keybindings.js";
// ============================================================================
// UI Context
@ -92,6 +94,7 @@ export interface ExtensionUIContext {
factory: (
tui: TUI,
theme: Theme,
keybindings: KeybindingsManager,
done: (result: T) => void,
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
): Promise<T>;
@ -105,6 +108,43 @@ export interface ExtensionUIContext {
/** Show a multi-line editor for text editing. */
editor(title: string, prefill?: string): Promise<string | undefined>;
/**
* Set a custom editor component via factory function.
* Pass undefined to restore the default editor.
*
* The factory receives:
* - `theme`: EditorTheme for styling borders and autocomplete
* - `keybindings`: KeybindingsManager for app-level keybindings
*
* For full app keybinding support (escape, ctrl+d, model switching, etc.),
* extend `CustomEditor` from `@mariozechner/pi-coding-agent` and call
* `super.handleInput(data)` for keys you don't handle.
*
* @example
* ```ts
* import { CustomEditor } from "@mariozechner/pi-coding-agent";
*
* class VimEditor extends CustomEditor {
* private mode: "normal" | "insert" = "insert";
*
* handleInput(data: string): void {
* if (this.mode === "normal") {
* // Handle vim normal mode keys...
* if (data === "i") { this.mode = "insert"; return; }
* }
* super.handleInput(data); // App keybindings + text editing
* }
* }
*
* ctx.ui.setEditorComponent((tui, theme, keybindings) =>
* new VimEditor(tui, theme, keybindings)
* );
* ```
*/
setEditorComponent(
factory: ((tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => EditorComponent) | undefined,
): void;
/** Get the current theme for styling. */
readonly theme: Theme;
}

View file

@ -29,7 +29,8 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
export interface ScopedModel {
model: Model<Api>;
thinkingLevel: ThinkingLevel;
/** Thinking level if explicitly specified in pattern (e.g., "model:high"), undefined otherwise */
thinkingLevel?: ThinkingLevel;
}
/**
@ -98,7 +99,8 @@ function tryMatchModel(modelPattern: string, availableModels: Model<Api>[]): Mod
export interface ParsedModelResult {
model: Model<Api> | undefined;
thinkingLevel: ThinkingLevel;
/** Thinking level if explicitly specified in pattern, undefined otherwise */
thinkingLevel?: ThinkingLevel;
warning: string | undefined;
}
@ -119,14 +121,14 @@ export function parseModelPattern(pattern: string, availableModels: Model<Api>[]
// Try exact match first
const exactMatch = tryMatchModel(pattern, availableModels);
if (exactMatch) {
return { model: exactMatch, thinkingLevel: "off", warning: undefined };
return { model: exactMatch, thinkingLevel: undefined, warning: undefined };
}
// No match - try splitting on last colon if present
const lastColonIndex = pattern.lastIndexOf(":");
if (lastColonIndex === -1) {
// No colons, pattern simply doesn't match any model
return { model: undefined, thinkingLevel: "off", warning: undefined };
return { model: undefined, thinkingLevel: undefined, warning: undefined };
}
const prefix = pattern.substring(0, lastColonIndex);
@ -137,22 +139,21 @@ export function parseModelPattern(pattern: string, availableModels: Model<Api>[]
const result = parseModelPattern(prefix, availableModels);
if (result.model) {
// Only use this thinking level if no warning from inner recursion
// (if there was an invalid suffix deeper, we already have "off")
return {
model: result.model,
thinkingLevel: result.warning ? "off" : suffix,
thinkingLevel: result.warning ? undefined : suffix,
warning: result.warning,
};
}
return result;
} else {
// Invalid suffix - recurse on prefix with "off" and warn
// Invalid suffix - recurse on prefix and warn
const result = parseModelPattern(prefix, availableModels);
if (result.model) {
return {
model: result.model,
thinkingLevel: "off",
warning: `Invalid thinking level "${suffix}" in pattern "${pattern}". Using "off" instead.`,
thinkingLevel: undefined,
warning: `Invalid thinking level "${suffix}" in pattern "${pattern}". Using default instead.`,
};
}
return result;
@ -180,7 +181,7 @@ export async function resolveModelScope(patterns: string[], modelRegistry: Model
// Extract optional thinking level suffix (e.g., "provider/*:high")
const colonIdx = pattern.lastIndexOf(":");
let globPattern = pattern;
let thinkingLevel: ThinkingLevel = "off";
let thinkingLevel: ThinkingLevel | undefined;
if (colonIdx !== -1) {
const suffix = pattern.substring(colonIdx + 1);
@ -282,7 +283,7 @@ export async function findInitialModel(options: {
if (scopedModels.length > 0 && !isContinuing) {
return {
model: scopedModels[0].model,
thinkingLevel: scopedModels[0].thinkingLevel,
thinkingLevel: scopedModels[0].thinkingLevel ?? defaultThinkingLevel ?? "off",
fallbackMessage: undefined,
};
}

View file

@ -531,6 +531,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
setEditorText: () => {},
getEditorText: () => "",
editor: async () => undefined,
setEditorComponent: () => {},
get theme() {
return {} as any;
},