Merge review/pr-477-fix

This commit is contained in:
can1357
2026-03-26 19:14:52 +01:00
11 changed files with 364 additions and 83 deletions
+3 -17
View File
@@ -21,6 +21,8 @@
### Added
- Added custom model roles/tags via config YAML
- Added ability to reorder model role/tag cycling via config YAML
- Added prompt for tradeoff metrics during autoresearch setup to collect secondary metrics alongside primary metric
- Added validation of contract path specifications to reject absolute paths and parent directory references
- Added stricter benchmark command validation in `isAutoresearchShCommand()` to reject chained commands, pipes, and redirects
@@ -78,19 +80,6 @@
### Changed
- Changed `isAutoresearchShCommand()` to use proper command-line argument parsing instead of regex, improving accuracy for complex shell invocations
- Changed autoresearch initialization prompt to display collected tradeoff metrics in the setup summary
- Changed `command-initialize.md` template to include guidance on preflight requirements, comparability invariants, and marking measurement-critical files as off-limits
- Changed `command-initialize.md` to instruct users to write or update `autoresearch.program.md` with durable heuristics and repo-specific strategy
- Changed autoresearch resume guidance to emphasize continuing on the current protected branch rather than switching branches
- Changed autoresearch prompt to clarify that `autoresearch.md` holds durable conclusions while `autoresearch.ideas.md` is the scratch backlog
- Changed autoresearch prompt guidance to require stable measurement harness and fixed benchmark inputs unless intentionally starting a new segment
- Changed autoresearch prompt to recommend keeping equal or near-equal results when they materially simplify implementation
- Changed `init_experiment` to reset pending run state (checks, duration, ASI, artifact directory) when initializing a new segment
- Changed `log_experiment` to set `autoResumeArmed` flag after successfully logging a run to enable auto-resume on next agent turn
- Changed `run_experiment` to set `autoResumeArmed` flag and update dashboard after completing a run
- Changed auto-resume logic to only prompt when a new pending run exists or when `autoResumeArmed` is explicitly set, preventing duplicate prompts
- Changed path normalization in contract validation to use `path.posix.normalize()` for consistent path handling
- Changed autoresearch initialization to collect and validate benchmark command, metric definition, scope paths, off-limits list, and constraints before `init_experiment`
- Changed `init_experiment` to require exact benchmark command, metric definition, scope, off-limits, and constraints matching collected contract
- Changed `log_experiment` to record run number, benchmark command, scope paths, off-limits list, constraints, and segment fingerprint with each result
@@ -140,9 +129,6 @@
### Fixed
- Fixed boundary duplication warnings to always display when replacement lines match the next surviving line, even when auto-correction is disabled
- Fixed secondary metrics validation to properly reject missing configured metrics and new metrics without force flag
- Fixed ASI data cloning to prevent prototype pollution attacks by filtering reserved property names
- Fixed autoresearch resume to detect and recover pending run artifacts that were left unlogged from previous sessions
- Fixed dashboard overlay to display when running experiment even with zero completed results
- Fixed tab character rendering in dashboard command display and tool output summaries
@@ -6344,4 +6330,4 @@ Initial public release.
- Git branch display in footer
- Message queueing during streaming responses
- OAuth integration for Gmail and Google Calendar access
- HTML export with syntax highlighting and collapsible sections
- HTML export with syntax highlighting and collapsible sections
@@ -28,8 +28,9 @@ import {
import { isRecord, logger } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import { type ConfigError, ConfigFile } from "../config";
import type { ThemeColor } from "../modes/theme/theme";
import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme";
import type { AuthStorage, OAuthCredential } from "../session/auth-storage";
import type { Settings } from "./settings";
export const kNoAuth = "N/A";
@@ -57,6 +58,55 @@ export const MODEL_ROLES: Record<ModelRole, ModelRoleInfo> = {
export const MODEL_ROLE_IDS: ModelRole[] = ["default", "smol", "slow", "vision", "plan", "commit", "task"];
/** Alias for ModelRoleInfo - used for both built-in and custom roles */
export type RoleInfo = ModelRoleInfo;
/**
* Return the canonical set of known roles for selector/carousel UI.
*
* Built-ins always come first. Configured cycle order, model assignments, and
* tag metadata can introduce additional custom roles without requiring duplicate
* entries across settings.
*/
export function getKnownRoleIds(settings: Settings): string[] {
// Avoid MODEL_ROLE_IDS here: this helper is reached during selector initialization,
// and model-registry participates in import cycles while the module is still evaluating.
const roles = ["default", "smol", "slow", "vision", "plan", "commit", "task"];
const seen = new Set<string>(roles);
const addRole = (role: string) => {
if (seen.has(role)) return;
seen.add(role);
roles.push(role);
};
for (const role of settings.get("cycleOrder")) addRole(role);
for (const role of Object.keys(settings.getModelRoles())) addRole(role);
for (const role of Object.keys(settings.get("modelTags"))) addRole(role);
return roles;
}
/**
* Get role info for a role name (built-in or custom).
* Configured metadata overrides built-in defaults when present.
*/
export function getRoleInfo(role: string, settings: Settings): RoleInfo {
const builtIn = role in MODEL_ROLES ? MODEL_ROLES[role as ModelRole] : undefined;
const configured = settings.get("modelTags")[role];
if (configured) {
return {
tag: builtIn?.tag,
name: configured.name || builtIn?.name || role,
color: configured.color && isValidThemeColor(configured.color) ? configured.color : builtIn?.color,
};
}
if (builtIn) return builtIn;
return { name: role, color: "muted" };
}
const OpenRouterRoutingSchema = Type.Object({
only: Type.Optional(Type.Array(Type.String())),
order: Type.Optional(Type.Array(Type.String())),
@@ -135,10 +135,21 @@ type SettingDef =
// Schema Definition
// ═══════════════════════════════════════════════════════════════════════════
export interface ModelTagDef {
name: string;
color?: string;
}
export interface ModelTagsSettings {
[key: string]: ModelTagDef;
}
// Typed defaults for array/record settings — named constants avoid `as` casts
// under `as const` while still letting SettingValue infer the correct element type.
const EMPTY_STRING_ARRAY: string[] = [];
const EMPTY_STRING_RECORD: Record<string, string> = {};
const DEFAULT_CYCLE_ORDER: string[] = ["smol", "default", "slow"];
const EMPTY_MODEL_TAGS_RECORD: ModelTagsSettings = {};
export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [
{
pattern: "^\\s*(cat|head|tail|less|more)\\s+",
@@ -195,6 +206,10 @@ export const SETTINGS_SCHEMA = {
modelRoles: { type: "record", default: EMPTY_STRING_RECORD },
modelTags: { type: "record", default: EMPTY_MODEL_TAGS_RECORD },
cycleOrder: { type: "array", default: DEFAULT_CYCLE_ORDER },
// ────────────────────────────────────────────────────────────────────────
// Appearance
// ────────────────────────────────────────────────────────────────────────
@@ -1767,6 +1782,8 @@ export interface GroupTypeMap {
thinkingBudgets: ThinkingBudgetsSettings;
stt: SttSettings;
modelRoles: Record<string, string>;
modelTags: ModelTagsSettings;
cycleOrder: string[];
}
export type GroupPrefix = keyof GroupTypeMap;
@@ -12,7 +12,8 @@ import {
type TUI,
visibleWidth,
} from "@oh-my-pi/pi-tui";
import { MODEL_ROLE_IDS, MODEL_ROLES, type ModelRegistry, type ModelRole } from "../../config/model-registry";
import type { ModelRegistry } from "../../config/model-registry";
import { getKnownRoleIds, getRoleInfo, MODEL_ROLE_IDS, MODEL_ROLES } from "../../config/model-registry";
import { resolveModelRoleValue } from "../../config/model-resolver";
import type { Settings } from "../../config/settings";
import { type ThemeColor, theme } from "../../modes/theme/theme";
@@ -43,22 +44,13 @@ interface RoleAssignment {
thinkingLevel: ThinkingLevel;
}
type RoleSelectCallback = (model: Model, role: ModelRole | null, thinkingLevel?: ThinkingLevel) => void;
type RoleSelectCallback = (model: Model, role: string | null, thinkingLevel?: ThinkingLevel) => void;
type CancelCallback = () => void;
interface MenuRoleAction {
label: string;
role: ModelRole;
role: string; // now accepts custom role strings
}
const MENU_ROLE_ACTIONS: MenuRoleAction[] = MODEL_ROLE_IDS.map(role => {
const roleInfo = MODEL_ROLES[role];
const roleLabel = roleInfo.tag ? `${roleInfo.tag} (${roleInfo.name})` : roleInfo.name;
return {
label: `Set as ${roleLabel}`,
role,
};
});
const ALL_TAB = "ALL";
/**
@@ -77,7 +69,7 @@ export class ModelSelectorComponent extends Container {
#allModels: ModelItem[] = [];
#filteredModels: ModelItem[] = [];
#selectedIndex: number = 0;
#roles = {} as Record<ModelRole, RoleAssignment | undefined>;
#roles = {} as Record<string, RoleAssignment | undefined>;
#settings = null as unknown as Settings;
#modelRegistry = null as unknown as ModelRegistry;
#onSelectCallback = (() => {}) as RoleSelectCallback;
@@ -87,6 +79,8 @@ export class ModelSelectorComponent extends Container {
#scopedModels: ReadonlyArray<ScopedModelItem>;
#temporaryOnly: boolean;
#menuRoleActions: MenuRoleAction[] = [];
// Tab state
#providers: string[] = [ALL_TAB];
#activeTabIndex: number = 0;
@@ -95,7 +89,7 @@ export class ModelSelectorComponent extends Container {
#isMenuOpen: boolean = false;
#menuSelectedIndex: number = 0;
#menuStep: "role" | "thinking" = "role";
#menuSelectedRole: ModelRole | null = null;
#menuSelectedRole: string | null = null;
constructor(
tui: TUI,
@@ -103,7 +97,7 @@ export class ModelSelectorComponent extends Container {
settings: Settings,
modelRegistry: ModelRegistry,
scopedModels: ReadonlyArray<ScopedModelItem>,
onSelect: (model: Model, role: ModelRole | null, thinkingLevel?: ThinkingLevel) => void,
onSelect: (model: Model, role: string | null, thinkingLevel?: ThinkingLevel) => void,
onCancel: () => void,
options?: { temporaryOnly?: boolean; initialSearchInput?: string },
) {
@@ -118,6 +112,9 @@ export class ModelSelectorComponent extends Container {
this.#temporaryOnly = options?.temporaryOnly ?? false;
const initialSearchInput = options?.initialSearchInput;
// Initialize menu role actions (built-in + custom from settings)
this.#buildMenuRoleActions();
// Load current role assignments from settings
this.#loadRoleModels();
@@ -184,22 +181,35 @@ export class ModelSelectorComponent extends Container {
});
}
#buildMenuRoleActions(): void {
this.#menuRoleActions = getKnownRoleIds(this.#settings).map(role => {
const roleInfo = getRoleInfo(role, this.#settings);
const roleLabel = roleInfo.tag ? `${roleInfo.tag} (${roleInfo.name})` : roleInfo.name;
return {
label: `Set as ${roleLabel}`,
role,
};
});
}
#loadRoleModels(): void {
const allModels = this.#modelRegistry.getAll();
const matchPreferences = { usageOrder: this.#settings.getStorage()?.getModelUsageOrder() };
for (const role of MODEL_ROLE_IDS) {
for (const role of getKnownRoleIds(this.#settings)) {
const roleValue = this.#settings.getModelRole(role);
if (!roleValue) continue;
const { model, thinkingLevel, explicitThinkingLevel } = resolveModelRoleValue(roleValue, allModels, {
const resolved = resolveModelRoleValue(roleValue, allModels, {
settings: this.#settings,
matchPreferences,
});
if (model) {
if (resolved.model) {
this.#roles[role] = {
model,
model: resolved.model,
thinkingLevel:
explicitThinkingLevel && thinkingLevel !== undefined ? thinkingLevel : ThinkingLevel.Inherit,
resolved.explicitThinkingLevel && resolved.thinkingLevel !== undefined
? resolved.thinkingLevel
: ThinkingLevel.Inherit,
};
}
}
@@ -470,7 +480,7 @@ export class ModelSelectorComponent extends Container {
// Build role badges (inverted: color as background, black text)
const roleBadgeTokens: string[] = [];
for (const role of MODEL_ROLE_IDS) {
const { tag, color } = MODEL_ROLES[role];
const { tag, color } = getRoleInfo(role, this.#settings);
const assigned = this.#roles[role];
if (!tag || !assigned || !modelsAreEqual(assigned.model, item.model)) continue;
@@ -478,6 +488,15 @@ export class ModelSelectorComponent extends Container {
const thinkingLabel = getThinkingLevelMetadata(assigned.thinkingLevel).label;
roleBadgeTokens.push(`${badge} ${theme.fg("dim", `(${thinkingLabel})`)}`);
}
// Custom role badges
for (const [role, assigned] of Object.entries(this.#roles)) {
if (role in MODEL_ROLES || !assigned || !modelsAreEqual(assigned.model, item.model)) continue;
const roleInfo = getRoleInfo(role, this.#settings);
const badgeLabel = roleInfo.tag ?? roleInfo.name;
const badge = makeInvertedBadge(badgeLabel, roleInfo.color ?? "muted");
const thinkingLabel = getThinkingLevelMetadata(assigned.thinkingLevel).label;
roleBadgeTokens.push(`${badge} ${theme.fg("dim", `(${thinkingLabel})`)}`);
}
const badgeText = roleBadgeTokens.length > 0 ? ` ${roleBadgeTokens.join(" ")}` : "";
let line = "";
@@ -527,11 +546,11 @@ export class ModelSelectorComponent extends Container {
return [ThinkingLevel.Inherit, ThinkingLevel.Off, ...getSupportedEfforts(model)];
}
#getCurrentRoleThinkingLevel(role: ModelRole): ThinkingLevel {
#getCurrentRoleThinkingLevel(role: string): ThinkingLevel {
return this.#roles[role]?.thinkingLevel ?? ThinkingLevel.Inherit;
}
#getThinkingPreselectIndex(role: ModelRole, model: Model): number {
#getThinkingPreselectIndex(role: string, model: Model): number {
const options = this.#getThinkingLevelsForModel(model);
const currentLevel = this.#getCurrentRoleThinkingLevel(role);
const foundIndex = options.indexOf(currentLevel);
@@ -569,12 +588,12 @@ export class ModelSelectorComponent extends Container {
const label = getThinkingLevelMetadata(thinkingLevel).label;
return `${prefix}${label}`;
})
: MENU_ROLE_ACTIONS.map((action, index) => {
: this.#menuRoleActions.map((action, index) => {
const prefix = index === this.#menuSelectedIndex ? ` ${theme.nav.cursor} ` : " ";
return `${prefix}${action.label}`;
});
const selectedRoleName = this.#menuSelectedRole ? MODEL_ROLES[this.#menuSelectedRole].name : "";
const selectedRoleName = this.#menuSelectedRole ? getRoleInfo(this.#menuSelectedRole, this.#settings).name : "";
const headerText =
showingThinking && this.#menuSelectedRole
? ` Thinking for: ${selectedRoleName} (${selectedModel.id})`
@@ -674,7 +693,7 @@ export class ModelSelectorComponent extends Container {
const optionCount =
this.#menuStep === "thinking" && this.#menuSelectedRole !== null
? this.#getThinkingLevelsForModel(selectedModel.model).length
: MENU_ROLE_ACTIONS.length;
: this.#menuRoleActions.length;
if (optionCount === 0) return;
if (matchesKey(keyData, "up")) {
@@ -691,7 +710,7 @@ export class ModelSelectorComponent extends Container {
if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
if (this.#menuStep === "role") {
const action = MENU_ROLE_ACTIONS[this.#menuSelectedIndex];
const action = this.#menuRoleActions[this.#menuSelectedIndex];
if (!action) return;
this.#menuSelectedRole = action.role;
this.#menuStep = "thinking";
@@ -712,7 +731,7 @@ export class ModelSelectorComponent extends Container {
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);
const roleIndex = this.#menuRoleActions.findIndex(action => action.role === this.#menuSelectedRole);
this.#menuSelectedRole = null;
this.#menuSelectedIndex = roleIndex >= 0 ? roleIndex : 0;
this.#updateMenu();
@@ -728,7 +747,7 @@ export class ModelSelectorComponent extends Container {
if (thinkingLevel === ThinkingLevel.Inherit) return modelKey;
return `${modelKey}:${thinkingLevel}`;
}
#handleSelect(model: Model, role: ModelRole | null, thinkingLevel?: ThinkingLevel): void {
#handleSelect(model: Model, role: string | null, thinkingLevel?: ThinkingLevel): void {
// For temporary role, don't save to settings - just notify caller
if (role === null) {
this.#onSelectCallback(model, null);
@@ -608,8 +608,8 @@ export class InputController {
async cycleRoleModel(options?: { temporary?: boolean }): Promise<void> {
try {
const roleOrder = ["smol", "default", "slow"] as const;
const result = await this.ctx.session.cycleRoleModels(roleOrder, options);
const cycleOrder = settings.get("cycleOrder");
const result = await this.ctx.session.cycleRoleModels(cycleOrder, options);
if (!result) {
this.ctx.showStatus("Only one role model available");
return;
@@ -625,7 +625,7 @@ export class InputController {
: "";
const tempLabel = options?.temporary ? " (temporary)" : "";
const cycleSeparator = theme.fg("dim", " > ");
const cycleLabel = roleOrder
const cycleLabel = cycleOrder
.map(role => {
if (role === result.role) {
return theme.bold(theme.fg("accent", role));
@@ -3,7 +3,7 @@ import { getOAuthProviders, type OAuthProvider } from "@oh-my-pi/pi-ai";
import type { Component } from "@oh-my-pi/pi-tui";
import { Input, Loader, Spacer, Text } from "@oh-my-pi/pi-tui";
import { getAgentDbPath, getProjectDir } from "@oh-my-pi/pi-utils";
import { MODEL_ROLES } from "../../config/model-registry";
import { getRoleInfo } from "../../config/model-registry";
import { settings } from "../../config/settings";
import { DebugSelectorComponent } from "../../debug";
import { disableProvider, enableProvider } from "../../discovery";
@@ -406,7 +406,7 @@ export class SelectorController {
// Don't call done() - selector stays open for role assignment
} else {
// Other roles (smol, slow): just update settings, not current model
const roleInfo = MODEL_ROLES[role];
const roleInfo = getRoleInfo(role, settings);
const roleLabel = roleInfo?.name ?? role;
this.ctx.showStatus(`${roleLabel} model: ${model.id}`);
// Don't call done() - selector stays open
@@ -955,6 +955,74 @@ export type ThemeColor =
| "statusLineCost"
| "statusLineSubagents";
/** Set of all valid ThemeColor string values for runtime validation */
const VALID_THEME_COLORS: ReadonlySet<string> = new Set([
"accent",
"border",
"borderAccent",
"borderMuted",
"success",
"error",
"warning",
"muted",
"dim",
"text",
"thinkingText",
"userMessageText",
"customMessageText",
"customMessageLabel",
"toolTitle",
"toolOutput",
"mdHeading",
"mdLink",
"mdLinkUrl",
"mdCode",
"mdCodeBlock",
"mdCodeBlockBorder",
"mdQuote",
"mdQuoteBorder",
"mdHr",
"mdListBullet",
"toolDiffAdded",
"toolDiffRemoved",
"toolDiffContext",
"syntaxComment",
"syntaxKeyword",
"syntaxFunction",
"syntaxVariable",
"syntaxString",
"syntaxNumber",
"syntaxType",
"syntaxOperator",
"syntaxPunctuation",
"thinkingOff",
"thinkingMinimal",
"thinkingLow",
"thinkingMedium",
"thinkingHigh",
"thinkingXhigh",
"bashMode",
"pythonMode",
"statusLineSep",
"statusLineModel",
"statusLinePath",
"statusLineGitClean",
"statusLineGitDirty",
"statusLineContext",
"statusLineSpend",
"statusLineStaged",
"statusLineDirty",
"statusLineUntracked",
"statusLineOutput",
"statusLineCost",
"statusLineSubagents",
]);
/** Check if a string is a valid ThemeColor value */
export function isValidThemeColor(color: string): color is ThemeColor {
return VALID_THEME_COLORS.has(color);
}
export type ThemeBg =
| "selectedBg"
| "userMessageBg"
@@ -53,7 +53,7 @@ import {
import { abortableSleep, getAgentDbPath, isEnoent, logger } from "@oh-my-pi/pi-utils";
import type { AsyncJob, AsyncJobManager } from "../async";
import type { Rule } from "../capability/rule";
import { MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "../config/model-registry";
import { MODEL_ROLE_IDS, type ModelRegistry } from "../config/model-registry";
import { extractExplicitThinkingSelector, parseModelString, resolveModelRoleValue } from "../config/model-resolver";
import { expandPromptTemplate, type PromptTemplate, renderPromptTemplate } from "../config/prompt-templates";
import type { Settings, SkillsSettings } from "../config/settings";
@@ -269,7 +269,7 @@ export interface ModelCycleResult {
export interface RoleModelCycleResult {
model: Model;
thinkingLevel: ThinkingLevel | undefined;
role: ModelRole;
role: string;
}
/** Session statistics for /session command */
@@ -2039,7 +2039,7 @@ export class AgentSession {
);
}
resolveRoleModel(role: ModelRole): Model | undefined {
resolveRoleModel(role: string): Model | undefined {
return this.#resolveRoleModel(role, this.#modelRegistry.getAvailable(), this.model);
}
@@ -3100,7 +3100,7 @@ export class AgentSession {
* Validates API key, saves to session and settings.
* @throws Error if no API key available for the model
*/
async setModel(model: Model, role: ModelRole = "default"): Promise<void> {
async setModel(model: Model, role: string = "default"): Promise<void> {
const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
if (!apiKey) {
throw new Error(`No API key for ${model.provider}/${model.id}`);
@@ -3154,7 +3154,7 @@ export class AgentSession {
* @param options - Optional settings: `temporary` to not persist to settings
*/
async cycleRoleModels(
roleOrder: readonly ModelRole[],
roleOrder: readonly string[],
options?: { temporary?: boolean },
): Promise<RoleModelCycleResult | undefined> {
const availableModels = this.#modelRegistry.getAvailable();
@@ -3164,7 +3164,7 @@ export class AgentSession {
if (!currentModel) return undefined;
const matchPreferences = { usageOrder: this.settings.getStorage()?.getModelUsageOrder() };
const roleModels: Array<{
role: ModelRole;
role: string;
model: Model;
thinkingLevel?: ThinkingLevel;
explicitThinkingLevel: boolean;
@@ -3194,9 +3194,10 @@ export class AgentSession {
if (roleModels.length <= 1) return undefined;
const lastRole = this.sessionManager.getLastModelChangeRole();
let currentIndex = lastRole
? roleModels.findIndex(entry => entry.role === lastRole)
: roleModels.findIndex(entry => modelsAreEqual(entry.model, currentModel));
let currentIndex = lastRole ? roleModels.findIndex(entry => entry.role === lastRole) : -1;
if (currentIndex === -1) {
currentIndex = roleModels.findIndex(entry => modelsAreEqual(entry.model, currentModel));
}
if (currentIndex === -1) currentIndex = 0;
const nextIndex = (currentIndex + 1) % roleModels.length;
@@ -4277,7 +4278,7 @@ export class AgentSession {
return `${model.provider}/${model.id}`;
}
#formatRoleModelValue(role: ModelRole, model: Model): string {
#formatRoleModelValue(role: string, model: Model): string {
const modelKey = `${model.provider}/${model.id}`;
const existingRoleValue = this.settings.getModelRole(role);
if (!existingRoleValue) return modelKey;
@@ -4299,7 +4300,7 @@ export class AgentSession {
return availableModels.find(m => m.provider === currentModel.provider && m.id === configuredTarget);
}
#resolveRoleModel(role: ModelRole, availableModels: Model[], currentModel: Model | undefined): Model | undefined {
#resolveRoleModel(role: string, availableModels: Model[], currentModel: Model | undefined): Model | undefined {
const roleModelStr =
role === "default"
? (this.settings.getModelRole("default") ??
@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
describe("cycleOrder with custom roles", () => {
test("cycleOrder setting accepts custom role names", () => {
const settings = Settings.isolated({
cycleOrder: ["smol", "custom-fast", "default"],
});
expect(settings.get("cycleOrder")).toEqual(["smol", "custom-fast", "default"]);
});
test("cycleOrder falls back to default when not set", () => {
const settings = Settings.isolated({});
expect(settings.get("cycleOrder")).toEqual(["smol", "default", "slow"]);
});
test("modelTags can define custom role display info", () => {
const settings = Settings.isolated({
modelTags: {
"custom-fast": {
name: "Fast Custom",
color: "warning",
},
},
});
const modelTags = settings.get("modelTags") as Record<string, any>;
expect(modelTags["custom-fast"]).toEqual({
name: "Fast Custom",
color: "warning",
});
});
});
@@ -1,9 +1,9 @@
import { beforeAll, describe, expect, test, vi } from "bun:test";
import { getBundledModel } from "@oh-my-pi/pi-ai";
import { getBundledModel, type Model } from "@oh-my-pi/pi-ai";
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 { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { TUI } from "@oh-my-pi/pi-tui";
function normalizeRenderedText(text: string): string {
@@ -17,9 +17,38 @@ function normalizeRenderedText(text: string): string {
);
}
function createSelector(model: Model, settings: Settings): ModelSelectorComponent {
const modelRegistry = {
getAll: () => [model],
getDiscoverableProviders: () => [],
} as unknown as ModelRegistry;
const ui = {
requestRender: vi.fn(),
} as unknown as TUI;
return new ModelSelectorComponent(
ui,
model,
settings,
modelRegistry,
[{ model, thinkingLevel: "off" }],
() => {},
() => {},
);
}
describe("ModelSelector role badge thinking display", () => {
beforeAll(() => {
initTheme();
setThemeInstance(
{
fg: (_color: string, text: string) => text,
bg: (_color: string, text: string) => text,
bold: (text: string) => text,
getFgAnsi: () => "\x1b[38;5;1m",
nav: { cursor: ">" },
boxSharp: { horizontal: "-" },
} as never,
);
});
test("renders per-role thinking labels with inherit mode to avoid badge ambiguity", async () => {
@@ -36,23 +65,7 @@ describe("ModelSelector role badge thinking display", () => {
},
});
const modelRegistry = {
getAll: () => [model],
getDiscoverableProviders: () => [],
} as unknown as ModelRegistry;
const ui = {
requestRender: vi.fn(),
} as unknown as TUI;
const selector = new ModelSelectorComponent(
ui,
model,
settings,
modelRegistry,
[{ model, thinkingLevel: "off" }],
() => {},
() => {},
);
const selector = createSelector(model, settings);
await Bun.sleep(0);
@@ -72,4 +85,33 @@ describe("ModelSelector role badge thinking display", () => {
expect(menuRendered).toContain("Set as PLAN (Architect)");
expect(menuRendered).toContain("Set as COMMIT (Commit)");
});
test("shows custom roles from cycleOrder/modelRoles and honors built-in metadata overrides", async () => {
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({
cycleOrder: ["smol", "custom-fast", "default"],
modelRoles: {
default: `${model.provider}/${model.id}`,
"custom-fast": `${model.provider}/${model.id}:low`,
smol: `${model.provider}/${model.id}`,
},
modelTags: {
smol: { name: "Quick", color: "error" },
},
});
const selector = createSelector(model, settings);
await Bun.sleep(0);
const rendered = normalizeRenderedText(selector.render(220).join("\n"));
expect(rendered).toContain("custom-fast (low)");
expect(rendered).toContain("SMOL (inherit)");
selector.handleInput("\n");
const menuRendered = normalizeRenderedText(selector.render(220).join("\n"));
expect(menuRendered).toContain("Set as custom-fast");
expect(menuRendered).toContain("Set as SMOL (Quick)");
});
});
@@ -0,0 +1,66 @@
import { describe, expect, test } from "bun:test";
import { getRoleInfo } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
describe("getRoleInfo", () => {
test("returns built-in role info", () => {
const settings = Settings.isolated({});
expect(getRoleInfo("default", settings)).toEqual({
name: "Default",
color: "success",
tag: "DEFAULT",
});
expect(getRoleInfo("smol", settings)).toEqual({
name: "Fast",
color: "warning",
tag: "SMOL",
});
expect(getRoleInfo("slow", settings)).toEqual({
name: "Thinking",
color: "accent",
tag: "SLOW",
});
});
test("returns custom role info from modelTags", () => {
const settings = Settings.isolated({
modelTags: {
custom: { name: "My Custom Tag", color: "error" },
another: { name: "Another Tag" },
},
});
expect(getRoleInfo("custom", settings)).toEqual({
name: "My Custom Tag",
color: "error",
});
expect(getRoleInfo("another", settings)).toEqual({
name: "Another Tag",
color: undefined,
});
});
test("returns fallback for unknown roles", () => {
const settings = Settings.isolated({});
expect(getRoleInfo("unknown-role", settings)).toEqual({
name: "unknown-role",
color: "muted",
});
});
test("configured metadata overrides built-in role info while keeping built-in defaults", () => {
const settings = Settings.isolated({
modelTags: {
smol: { name: "My Smol", color: "success" },
},
});
expect(getRoleInfo("smol", settings)).toEqual({
tag: "SMOL",
name: "My Smol",
color: "success",
});
});
});