Merge review/pr-477-fix
This commit is contained in:
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user