feat(ai): add max thinking level

This commit is contained in:
Mario Zechner
2026-07-09 22:30:53 +02:00
parent 8973ae28ab
commit fbdd46389c
61 changed files with 441 additions and 168 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added the opt-in `max` thinking level across CLI, SDK, RPC, model selection, and themes. Custom themes can define `thinkingMax`; existing themes fall back to `thinkingXhigh`.
## [0.80.5] - 2026-07-09
## [0.80.4] - 2026-07-09
+1 -1
View File
@@ -551,7 +551,7 @@ cat README.md | pi -p "Summarize this text"
| `--provider <name>` | Provider (anthropic, openai, google, etc.) |
| `--model <pattern>` | Model pattern or ID (supports `provider/id` and optional `:<thinking>`) |
| `--api-key <key>` | API key (overrides env vars) |
| `--thinking <level>` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh` |
| `--thinking <level>` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max` |
| `--models <patterns>` | Comma-separated patterns for Ctrl+P cycling |
| `--list-models [search]` | List available models |
@@ -204,7 +204,7 @@ The `api` field determines which streaming implementation is used:
| `google-vertex` | Google Vertex AI API |
| `bedrock-converse-stream` | Amazon Bedrock Converse API |
Most OpenAI-compatible providers work with `openai-completions`. Use model-level `thinkingLevelMap` for model-specific thinking levels, and `compat` for provider quirks:
Most OpenAI-compatible providers work with `openai-completions`. Use model-level `thinkingLevelMap` for model-specific thinking levels, and `compat` for provider quirks. The `xhigh` and `max` levels are opt-in, require non-null map entries, and may be separated by unsupported holes:
```typescript
models: [{
@@ -216,7 +216,8 @@ models: [{
low: null,
medium: null,
high: "default",
xhigh: "max"
xhigh: null,
max: "max"
},
compat: {
supportsDeveloperRole: false, // use "system" instead of "developer"
@@ -684,7 +685,7 @@ interface ProviderModelConfig {
reasoning: boolean;
/** Maps pi thinking levels to provider/model-specific values; null marks a level unsupported. */
thinkingLevelMap?: Partial<Record<"off" | "minimal" | "low" | "medium" | "high" | "xhigh", string | null>>;
thinkingLevelMap?: Partial<Record<"off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max", string | null>>;
/** Supported input types. */
input: ("text" | "image")[];
+1 -1
View File
@@ -1657,7 +1657,7 @@ if (model) {
Get or set the thinking level. Level is clamped to model capabilities (non-reasoning models always use "off"). Changes emit `thinking_level_select`.
```typescript
const current = pi.getThinkingLevel(); // "off" | "minimal" | "low" | "medium" | "high" | "xhigh"
const current = pi.getThinkingLevel(); // "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"
pi.setThinkingLevel("high");
```
+4 -3
View File
@@ -214,13 +214,13 @@ Current behavior:
### Thinking Level Map
Use `thinkingLevelMap` on a model to describe model-specific thinking controls. Keys are pi thinking levels: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`.
Use `thinkingLevelMap` on a model to describe model-specific thinking controls. Keys are pi thinking levels: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Maps may contain holes; for example, a model can expose `high` and `max` without exposing `xhigh`.
Values are tristate:
| Value | Meaning |
|-------|---------|
| omitted | Level is supported and uses the provider's default mapping |
| omitted | Standard levels through `high` use the provider's default mapping; extended `xhigh` and `max` levels are unsupported |
| string | Level is supported and this value is sent to the provider |
| `null` | Level is unsupported and hidden/skipped/clamped away |
@@ -235,7 +235,8 @@ Example for a model that only supports off, high, and max reasoning:
"low": null,
"medium": null,
"high": "high",
"xhigh": "max"
"xhigh": null,
"max": "max"
}
}
```
+2 -2
View File
@@ -286,9 +286,9 @@ Set the reasoning/thinking level for models that support it.
{"type": "set_thinking_level", "level": "high"}
```
Levels: `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`
Levels: `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"`
Note: `"xhigh"` is only supported by OpenAI codex-max models.
`"xhigh"` and `"max"` are exposed only when supported by the selected model. Some models, including GPT-5.6, expose both.
Response:
```json
+1 -1
View File
@@ -387,7 +387,7 @@ const available = await modelRegistry.getAvailable();
const { session } = await createAgentSession({
model: opus,
thinkingLevel: "medium", // off, minimal, low, medium, high, xhigh
thinkingLevel: "medium", // off, minimal, low, medium, high, xhigh, max
// Models for cycling (Ctrl+P in interactive mode)
scopedModels: [
+1 -1
View File
@@ -29,7 +29,7 @@ Use `/trust` in interactive mode to save a project trust decision for future ses
|---------|------|---------|-------------|
| `defaultProvider` | string | - | Default provider (e.g., `"anthropic"`, `"openai"`) |
| `defaultModel` | string | - | Default model ID |
| `defaultThinkingLevel` | string | - | `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"` |
| `defaultThinkingLevel` | string | - | `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"` |
| `hideThinkingBlock` | boolean | `false` | Hide thinking blocks in output |
| `showCacheMissNotices` | boolean | `false` | Show transcript notices for significant prompt-cache misses |
| `thinkingBudgets` | object | - | Custom token budgets per thinking level |
+5 -3
View File
@@ -109,6 +109,7 @@ vim ~/.pi/agent/themes/my-theme.json
"thinkingMedium": "#00ffff",
"thinkingHigh": "#ff00ff",
"thinkingXhigh": "#ff0000",
"thinkingMax": "#ff0088",
"bashMode": "#ffaa00"
}
}
@@ -139,13 +140,13 @@ vim ~/.pi/agent/themes/my-theme.json
- `name` is required, must be unique, and must not contain `/`.
- `vars` is optional. Define reusable colors here, then reference them in `colors`.
- `colors` must define all 51 required tokens.
- `colors` must define all 51 required tokens. `thinkingMax` is optional and falls back to `thinkingXhigh`.
The `$schema` field enables editor auto-completion and validation.
## Color Tokens
Every theme must define all 51 color tokens. There are no optional colors.
Every theme must define all 51 required color tokens. `thinkingMax` is optional for compatibility with existing themes; when omitted, it uses `thinkingXhigh`.
### Core UI (11 colors)
@@ -216,7 +217,7 @@ Every theme must define all 51 color tokens. There are no optional colors.
| `syntaxOperator` | Operators |
| `syntaxPunctuation` | Punctuation |
### Thinking Level Borders (6 colors)
### Thinking Level Borders (6 required, 1 optional)
Editor border colors indicating thinking level (visual hierarchy from subtle to prominent):
@@ -228,6 +229,7 @@ Editor border colors indicating thinking level (visual hierarchy from subtle to
| `thinkingMedium` | Medium thinking |
| `thinkingHigh` | High thinking |
| `thinkingXhigh` | Extra high thinking |
| `thinkingMax` | Maximum thinking; optional, falls back to `thinkingXhigh` |
### Bash Mode (1 color)
+1 -1
View File
@@ -424,7 +424,7 @@ renderResult(result, options, theme, context) {
| Diffs | `toolDiffAdded`, `toolDiffRemoved`, `toolDiffContext` |
| Markdown | `mdHeading`, `mdLink`, `mdLinkUrl`, `mdCode`, `mdCodeBlock`, `mdCodeBlockBorder`, `mdQuote`, `mdQuoteBorder`, `mdHr`, `mdListBullet` |
| Syntax | `syntaxComment`, `syntaxKeyword`, `syntaxFunction`, `syntaxVariable`, `syntaxString`, `syntaxNumber`, `syntaxType`, `syntaxOperator`, `syntaxPunctuation` |
| Thinking | `thinkingOff`, `thinkingMinimal`, `thinkingLow`, `thinkingMedium`, `thinkingHigh`, `thinkingXhigh` |
| Thinking | `thinkingOff`, `thinkingMinimal`, `thinkingLow`, `thinkingMedium`, `thinkingHigh`, `thinkingXhigh`, `thinkingMax` |
| Modes | `bashMode` |
**Background colors** (`theme.bg(color, text)`):
+1 -1
View File
@@ -183,7 +183,7 @@ cat README.md | pi -p "Summarize this text"
| `--provider <name>` | Provider, such as `anthropic`, `openai`, or `google` |
| `--model <pattern>` | Model pattern or ID; supports `provider/id` and optional `:<thinking>` |
| `--api-key <key>` | API key, overriding environment variables |
| `--thinking <level>` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh` |
| `--thinking <level>` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max` |
| `--models <patterns>` | Comma-separated patterns for Ctrl+P cycling |
| `--list-models [search]` | List available models |
@@ -52,7 +52,7 @@ interface Preset {
/** Model ID (e.g., "claude-sonnet-4-5") */
model?: string;
/** Thinking level */
thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
/** Tools to enable (replaces default set) */
tools?: string[];
/** Instructions to append to system prompt */
@@ -100,7 +100,7 @@ function loadPresets(cwd: string): PresetsConfig {
interface OriginalState {
model: Model<Api> | undefined;
thinkingLevel: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
thinkingLevel: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
tools: string[];
}
+2 -2
View File
@@ -54,7 +54,7 @@ export interface Args {
diagnostics: Array<{ type: "warning" | "error"; message: string }>;
}
const VALID_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
const VALID_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
export function isValidThinkingLevel(level: string): level is ThinkingLevel {
return VALID_THINKING_LEVELS.includes(level as ThinkingLevel);
@@ -258,7 +258,7 @@ ${chalk.bold("Options:")}
Applies to built-in, extension, and custom tools
--exclude-tools, -xt <tools> Comma-separated denylist of tool names to disable
Applies to built-in, extension, and custom tools
--thinking <level> Set thinking level: off, minimal, low, medium, high, xhigh
--thinking <level> Set thinking level: off, minimal, low, medium, high, xhigh, max
--extension, -e <path> Load an extension file (can be used multiple times)
--no-extensions, -ne Disable extension discovery (explicit -e paths still work)
--skill <path> Load a skill file or directory (can be used multiple times)
@@ -94,6 +94,7 @@ const ThinkingLevelMapSchema = Type.Object({
medium: Type.Optional(ThinkingLevelMapValueSchema),
high: Type.Optional(ThinkingLevelMapValueSchema),
xhigh: Type.Optional(ThinkingLevelMapValueSchema),
max: Type.Optional(ThinkingLevelMapValueSchema),
});
const ChatTemplateKwargScalarSchema = Type.Union([Type.String(), Type.Number(), Type.Boolean(), Type.Null()]);
@@ -1,3 +1,4 @@
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { Transport } from "@earendil-works/pi-ai";
import { randomUUID } from "crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
@@ -83,7 +84,7 @@ export interface Settings {
lastChangelogVersion?: string;
defaultProvider?: string;
defaultModel?: string;
defaultThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
defaultThinkingLevel?: ThinkingLevel;
transport?: TransportSetting; // default: "auto"
steeringMode?: "all" | "one-at-a-time";
followUpMode?: "all" | "one-at-a-time";
@@ -736,11 +737,11 @@ export class SettingsManager {
this.save();
}
getDefaultThinkingLevel(): "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | undefined {
getDefaultThinkingLevel(): ThinkingLevel | undefined {
return this.settings.defaultThinkingLevel;
}
setDefaultThinkingLevel(level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"): void {
setDefaultThinkingLevel(level: ThinkingLevel): void {
this.globalSettings.defaultThinkingLevel = level;
this.markModified("defaultThinkingLevel");
this.save();
@@ -35,7 +35,8 @@ const THINKING_DESCRIPTIONS: Record<ThinkingLevel, string> = {
low: "Light reasoning (~2k tokens)",
medium: "Moderate reasoning (~8k tokens)",
high: "Deep reasoning (~16k tokens)",
xhigh: "Maximum reasoning (~32k tokens)",
xhigh: "Extra-high reasoning (~32k tokens)",
max: "Maximum reasoning",
};
const DEFAULT_PROJECT_TRUST_LABELS: Record<DefaultProjectTrust, string> = {
@@ -14,7 +14,8 @@ const LEVEL_DESCRIPTIONS: Record<ThinkingLevel, string> = {
low: "Light reasoning (~2k tokens)",
medium: "Moderate reasoning (~8k tokens)",
high: "Deep reasoning (~16k tokens)",
xhigh: "Maximum reasoning (~32k tokens)",
xhigh: "Extra-high reasoning (~32k tokens)",
max: "Maximum reasoning",
};
/**
@@ -75,6 +75,7 @@
"thinkingMedium": "#81a2be",
"thinkingHigh": "#b294bb",
"thinkingXhigh": "#d183e8",
"thinkingMax": "#ff5fff",
"bashMode": "green"
},
@@ -74,6 +74,7 @@
"thinkingMedium": "teal",
"thinkingHigh": "#875f87",
"thinkingXhigh": "#8b008b",
"thinkingMax": "#af005f",
"bashMode": "green"
},
@@ -34,7 +34,7 @@
},
"colors": {
"type": "object",
"description": "Theme color definitions (all required)",
"description": "Theme color definitions (thinkingMax is optional and falls back to thinkingXhigh)",
"required": [
"accent",
"border",
@@ -287,7 +287,11 @@
},
"thinkingXhigh": {
"$ref": "#/$defs/colorValue",
"description": "Thinking level border: xhigh (OpenAI codex-max only)"
"description": "Thinking level border: xhigh"
},
"thinkingMax": {
"$ref": "#/$defs/colorValue",
"description": "Thinking level border: max (falls back to thinkingXhigh when omitted)"
},
"bashMode": {
"$ref": "#/$defs/colorValue",
@@ -1,5 +1,6 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import {
type EditorTheme,
getCapabilities,
@@ -88,6 +89,7 @@ const ThemeJsonSchema = Type.Object({
thinkingMedium: ColorValueSchema,
thinkingHigh: ColorValueSchema,
thinkingXhigh: ColorValueSchema,
thinkingMax: Type.Optional(ColorValueSchema),
// Bash Mode (1 color)
bashMode: ColorValueSchema,
}),
@@ -149,6 +151,7 @@ export type ThemeColor =
| "thinkingMedium"
| "thinkingHigh"
| "thinkingXhigh"
| "thinkingMax"
| "bashMode";
export type ThemeBg =
@@ -316,6 +319,10 @@ function resolveThemeColors<T extends Record<string, ColorValue>>(
return resolved as Record<keyof T, string | number>;
}
function withThemeColorFallbacks(colors: ThemeJson["colors"]): ThemeJson["colors"] & { thinkingMax: ColorValue } {
return { ...colors, thinkingMax: colors.thinkingMax ?? colors.thinkingXhigh };
}
// ============================================================================
// Theme Class
// ============================================================================
@@ -339,7 +346,8 @@ export class Theme {
this.sourceInfo = options.sourceInfo;
this.mode = mode;
this.fgColors = new Map();
for (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) {
const colors = { ...fgColors, thinkingMax: fgColors.thinkingMax ?? fgColors.thinkingXhigh };
for (const [key, value] of Object.entries(colors) as [ThemeColor, string | number][]) {
this.fgColors.set(key, fgAnsi(value, mode));
}
this.bgColors = new Map();
@@ -396,7 +404,7 @@ export class Theme {
return this.mode;
}
getThinkingBorderColor(level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"): (str: string) => string {
getThinkingBorderColor(level: ThinkingLevel): (str: string) => string {
// Map thinking levels to dedicated theme colors
switch (level) {
case "off":
@@ -411,6 +419,8 @@ export class Theme {
return (str: string) => this.fg("thinkingHigh", str);
case "xhigh":
return (str: string) => this.fg("thinkingXhigh", str);
case "max":
return (str: string) => this.fg("thinkingMax", str);
default:
return (str: string) => this.fg("thinkingOff", str);
}
@@ -586,7 +596,7 @@ function loadThemeJson(name: string): ThemeJson {
function createTheme(themeJson: ThemeJson, mode?: ColorMode, sourcePath?: string): Theme {
const colorMode = mode ?? (getCapabilities().trueColor ? "truecolor" : "256color");
const resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars);
const resolvedColors = resolveThemeColors(withThemeColorFallbacks(themeJson.colors), themeJson.vars);
const fgColors: Record<ThemeColor, string | number> = {} as Record<ThemeColor, string | number>;
const bgColors: Record<ThemeBg, string | number> = {} as Record<ThemeBg, string | number>;
const bgColorKeys: Set<string> = new Set([
@@ -1013,7 +1023,7 @@ export function getResolvedThemeColors(themeName?: string): Record<string, strin
const name = themeName ?? currentThemeName ?? getDefaultTheme();
const isLight = name === "light";
const themeJson = loadThemeJson(name);
const resolved = resolveThemeColors(themeJson.colors, themeJson.vars);
const resolved = resolveThemeColors(withThemeColorFallbacks(themeJson.colors), themeJson.vars);
// Default text color for empty values (terminal uses default fg color)
const defaultText = isLight ? "#000000" : "#e5e5e7";
@@ -0,0 +1,45 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { isValidThinkingLevel } from "../src/cli/args.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
import { loadThemeFromPath } from "../src/modes/interactive/theme/theme.ts";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("max thinking level", () => {
it("is accepted by CLI and settings", async () => {
expect(isValidThinkingLevel("max")).toBe(true);
const settings = SettingsManager.inMemory();
settings.setDefaultThinkingLevel("max");
await settings.flush();
expect(settings.getDefaultThinkingLevel()).toBe("max");
});
it("falls back to thinkingXhigh for legacy themes", () => {
const testDir = mkdtempSync(join(tmpdir(), "pi-max-theme-"));
tempDirs.push(testDir);
const currentDir = dirname(fileURLToPath(import.meta.url));
const darkTheme = JSON.parse(
readFileSync(join(currentDir, "../src/modes/interactive/theme/dark.json"), "utf8"),
) as { name: string; colors: Record<string, unknown> };
darkTheme.name = "legacy-theme";
delete darkTheme.colors.thinkingMax;
const themePath = join(testDir, "legacy-theme.json");
writeFileSync(themePath, JSON.stringify(darkTheme));
const legacyTheme = loadThemeFromPath(themePath);
expect(legacyTheme.getThinkingBorderColor("max")("border")).toBe(
legacyTheme.getThinkingBorderColor("xhigh")("border"),
);
});
});
@@ -107,7 +107,7 @@ describe("parseModelPattern", () => {
});
test("all valid thinking levels work", () => {
for (const level of ["off", "minimal", "low", "medium", "high", "xhigh"]) {
for (const level of ["off", "minimal", "low", "medium", "high", "xhigh", "max"]) {
const result = parseModelPattern(`sonnet:${level}`, allModels);
expect(result.model?.id).toBe("claude-sonnet-4-5");
expect(result.thinkingLevel).toBe(level);
@@ -520,7 +520,7 @@ describe("resolveCliModel", () => {
getAll: () => modelsWithNeuralwatt,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
for (const level of ["off", "minimal", "low", "medium", "high", "xhigh"]) {
for (const level of ["off", "minimal", "low", "medium", "high", "xhigh", "max"]) {
const result = resolveCliModel({
cliModel: `neuralwatt/zai-org/GLM-5.1-FP8:${level}`,
modelRegistry: registry,
@@ -78,6 +78,26 @@ describe("AgentSession model and extension characterization", () => {
expect(harness.session.cycleThinkingLevel()).toBeUndefined();
});
it("cycles xhigh before max when both are supported", async () => {
const harness = await createHarness({ models: [{ id: "faux-1", reasoning: true }] });
harnesses.push(harness);
harness.getModel().thinkingLevelMap = { xhigh: "xhigh", max: "max" };
expect(harness.session.getAvailableThinkingLevels()).toEqual([
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
]);
harness.session.setThinkingLevel("high");
expect(harness.session.cycleThinkingLevel()).toBe("xhigh");
expect(harness.session.cycleThinkingLevel()).toBe("max");
expect(harness.session.cycleThinkingLevel()).toBe("off");
});
it("throws when setModel is called without configured auth", async () => {
const harness = await createHarness({
models: [