feat(coding-agent): implemented role switching and filtering in model picker
- Added support for role switching via `@` search prefix in the model picker. - Implemented `quickRoles` configuration and logic to handle role-specific selections via the session API. - Updated the model browser to support preserving query order and custom label coloring for improved navigation. - Integrated role cycle tracking and updated UI hints to support the new role-browsing workflow.
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
|
||||
- Added display setting to toggle between collapsing or keeping compacted history inline, now applied to live session displays
|
||||
- Added a compact session-only model picker (Alt+P) for quick model switching without changing roles
|
||||
- Added `@` search to the Alt+P / `/switch` picker: it lists configured Ctrl+P quick roles in matching segment colors and applies the selected role's model and thinking for the current session.
|
||||
- Redesigned Agent Hub entries as two-line cards: identity (status glyph, name, agent type, parent when nested) on the left, active model + reasoning level and age right-aligned, with the task description on its own line; dropped the redundant `sub · of Main` noise
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -27,7 +27,7 @@ import { getKnownRoleIds, getRoleInfo, MODEL_ROLE_IDS } from "../../config/model
|
||||
import type { Settings } from "../../config/settings";
|
||||
import type { ModelPerfStats } from "../../session/agent-storage";
|
||||
import { AUTO_THINKING, type ConfiguredThinkingLevel, parseConfiguredThinkingLevel } from "../../thinking";
|
||||
import { theme } from "../theme/theme";
|
||||
import { type ThemeColor, theme } from "../theme/theme";
|
||||
import {
|
||||
matchesSelectCancel,
|
||||
matchesSelectDown,
|
||||
@@ -36,12 +36,14 @@ import {
|
||||
matchesSelectUp,
|
||||
} from "../utils/keybinding-matchers";
|
||||
|
||||
/** One selectable model row. `selector` is the canonical `provider/id` key. */
|
||||
/** One selectable row. `selector` is a canonical model key or host-specific virtual key. */
|
||||
export interface ModelBrowserItem {
|
||||
provider: string;
|
||||
id: string;
|
||||
model: Model;
|
||||
selector: string;
|
||||
/** Optional foreground color for the row label. */
|
||||
labelColor?: ThemeColor;
|
||||
}
|
||||
|
||||
/** Resolved role assignment as displayed by the browser and the hub. */
|
||||
@@ -351,6 +353,8 @@ export class ModelBrowser implements Component {
|
||||
#currentContextTokens: number;
|
||||
#disableOverContext: boolean;
|
||||
#emptyText?: () => string | undefined;
|
||||
/** Keep role-like virtual rows in their host-defined order during search. */
|
||||
#preserveQueryOrder = false;
|
||||
/** First visible list row; panned by the wheel, snapped to the selection on keyboard navigation. */
|
||||
#windowStart = 0;
|
||||
#windowCount = 0;
|
||||
@@ -412,6 +416,14 @@ export class ModelBrowser implements Component {
|
||||
setShowProvider(show: boolean): void {
|
||||
this.#showProvider = show;
|
||||
}
|
||||
/** Keep the source order after fuzzy filtering instead of applying model-specific ranking. */
|
||||
setPreserveQueryOrder(preserve: boolean): void {
|
||||
this.#preserveQueryOrder = preserve;
|
||||
}
|
||||
/** Allow hosts to toggle context-window eligibility between browser modes. */
|
||||
setDisableOverContext(disable: boolean): void {
|
||||
this.#disableOverContext = disable;
|
||||
}
|
||||
/** Focused: accent cursor + selected-row background band. Unfocused: dim cursor, no band. */
|
||||
setFocused(focused: boolean): void {
|
||||
this.#focused = focused;
|
||||
@@ -560,17 +572,21 @@ export class ModelBrowser implements Component {
|
||||
// queries all flow through the same fuzzy matcher.
|
||||
const ranked = fuzzyRank(this.#baseItems, query, ({ provider, id }) => `${provider}/${id}`);
|
||||
const matches = ranked.map(result => result.item);
|
||||
// Match quality is the primary key while searching: an exact
|
||||
// "gpt-5.5" must beat the MRU (or role-assigned) "gpt-5.6", so
|
||||
// role rank is skipped and MRU only breaks ties. Scores are
|
||||
// bucketed so sub-point position noise (provider-name length)
|
||||
// can't split equally good matches; within a bucket the stable
|
||||
// sort keeps sortModelItems' MRU/version order.
|
||||
sortModelItems(matches, { roles: this.#roles, mruOrder: this.#mruOrder, skipRoleRank: true });
|
||||
const buckets = new Map<ModelBrowserItem, number>();
|
||||
for (const result of ranked) buckets.set(result.item, Math.round(result.score / 10));
|
||||
matches.sort((a, b) => (buckets.get(a) ?? 0) - (buckets.get(b) ?? 0));
|
||||
items = matches;
|
||||
if (this.#preserveQueryOrder) {
|
||||
items = matches;
|
||||
} else {
|
||||
// Match quality is the primary key while searching: an exact
|
||||
// "gpt-5.5" must beat the MRU (or role-assigned) "gpt-5.6", so
|
||||
// role rank is skipped and MRU only breaks ties. Scores are
|
||||
// bucketed so sub-point position noise (provider-name length)
|
||||
// can't split equally good matches; within a bucket the stable
|
||||
// sort keeps sortModelItems' MRU/version order.
|
||||
sortModelItems(matches, { roles: this.#roles, mruOrder: this.#mruOrder, skipRoleRank: true });
|
||||
const buckets = new Map<ModelBrowserItem, number>();
|
||||
for (const result of ranked) buckets.set(result.item, Math.round(result.score / 10));
|
||||
matches.sort((a, b) => (buckets.get(a) ?? 0) - (buckets.get(b) ?? 0));
|
||||
items = matches;
|
||||
}
|
||||
} else {
|
||||
items = this.#baseItems;
|
||||
}
|
||||
@@ -705,7 +721,11 @@ export class ModelBrowser implements Component {
|
||||
const disabled = this.#isDisabled(item);
|
||||
const prefix = selected && this.#focused ? `${theme.fg("accent", theme.nav.cursor)} ` : " ";
|
||||
const providerPrefix = this.#showProvider ? theme.fg("dim", `${item.provider}/`) : "";
|
||||
const name = selected ? theme.fg("accent", item.id) : item.id;
|
||||
const name = item.labelColor
|
||||
? theme.fg(item.labelColor, item.id)
|
||||
: selected
|
||||
? theme.fg("accent", item.id)
|
||||
: item.id;
|
||||
const currentMark =
|
||||
item.selector === this.#currentSelector ? ` ${theme.fg("success", theme.status.enabled)}` : "";
|
||||
const overLimit = disabled
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
/**
|
||||
* Compact session-model picker (alt+p / `/switch`): a bottom-anchored
|
||||
* floating overlay hosting just a {@link ModelBrowser} — no provider sidebar,
|
||||
* no role management. Picking a model switches the current session only;
|
||||
* role assignment lives in the fullscreen /models hub ({@link ./model-hub}).
|
||||
* floating overlay hosting just a {@link ModelBrowser} — no provider sidebar.
|
||||
* Model entries switch the current session only; a search beginning with `@`
|
||||
* exposes the configured ctrl+p quick roles.
|
||||
*/
|
||||
import type { Model } from "@oh-my-pi/pi-ai";
|
||||
import type { Component, TUI } from "@oh-my-pi/pi-tui";
|
||||
import type { ModelRegistry } from "../../config/model-registry";
|
||||
import type { Settings } from "../../config/settings";
|
||||
import type { ResolvedRoleModel } from "../../session/agent-session";
|
||||
import { theme } from "../theme/theme";
|
||||
import { buildBrowserItems, ModelBrowser, resolveRoleAssignments, sortModelItems } from "./model-browser";
|
||||
import {
|
||||
buildBrowserItems,
|
||||
ModelBrowser,
|
||||
type ModelBrowserItem,
|
||||
resolveRoleAssignments,
|
||||
sortModelItems,
|
||||
} from "./model-browser";
|
||||
import type { ScopedModelItem } from "./model-hub";
|
||||
import { bottomBorder, row, topBorder } from "./overlay-box";
|
||||
import { resolveSegmentPalette } from "./segment-track";
|
||||
|
||||
export interface ModelPickerCallbacks {
|
||||
/** A model was chosen for a session-only switch. `selector` is `provider/id`. */
|
||||
onPick: (model: Model, selector: string) => void;
|
||||
/** A configured ctrl+p quick role was chosen. */
|
||||
onPickRole?: (entry: ResolvedRoleModel) => void;
|
||||
/** The picker was dismissed. */
|
||||
onCancel: () => void;
|
||||
}
|
||||
@@ -25,6 +35,12 @@ export interface ModelPickerOptions {
|
||||
currentContextTokens?: number;
|
||||
/** `provider/id` of the session's active model; highlighted and preselected. */
|
||||
currentSelector?: string;
|
||||
/** Resolved role models in the same order used by the ctrl+p quick-role cycle. */
|
||||
quickRoles?: ReadonlyArray<ResolvedRoleModel>;
|
||||
/** Complete ctrl+p order, including unavailable roles, to preserve segment colors. */
|
||||
quickRoleOrder?: ReadonlyArray<string>;
|
||||
/** Active quick role, highlighted when the search begins with `@`. */
|
||||
currentQuickRole?: string;
|
||||
}
|
||||
|
||||
/** Fixed chrome rows: top border, status row, footer, bottom border. */
|
||||
@@ -37,7 +53,9 @@ const MIN_VISIBLE = 5;
|
||||
const HEIGHT_FRACTION = 0.4;
|
||||
|
||||
const STATUS_HINT = "Session-only switch — role models stay unchanged";
|
||||
const FOOTER_HINT = "↑/↓ models · Enter use for this session · type to search · Esc close";
|
||||
const QUICK_ROLE_STATUS_HINT = "Quick role switch — applies its model and thinking for this session";
|
||||
const FOOTER_HINT = "↑/↓ models · Enter use for this session · type to search · @ quick roles · Esc close";
|
||||
const QUICK_ROLE_FOOTER_HINT = "↑/↓ roles · Enter apply role model · type to search · Esc close";
|
||||
|
||||
/**
|
||||
* The alt+p picker component. Hosted as a non-fullscreen bottom-anchored
|
||||
@@ -51,6 +69,12 @@ export class ModelPickerComponent implements Component {
|
||||
#scopedModels: ReadonlyArray<ScopedModelItem>;
|
||||
#browser: ModelBrowser;
|
||||
#configError: string | undefined;
|
||||
#currentSelector: string | undefined;
|
||||
#currentQuickRoleSelector: string | undefined;
|
||||
#modelItems: ModelBrowserItem[] = [];
|
||||
#quickRoleItems: ModelBrowserItem[] = [];
|
||||
#quickRoles = new Map<string, ResolvedRoleModel>();
|
||||
#roleMode = false;
|
||||
|
||||
constructor(
|
||||
tui: TUI,
|
||||
@@ -64,14 +88,28 @@ export class ModelPickerComponent implements Component {
|
||||
this.#settings = settings;
|
||||
this.#registry = registry;
|
||||
this.#scopedModels = scopedModels;
|
||||
this.#currentSelector = options.currentSelector;
|
||||
this.#currentQuickRoleSelector = options.currentQuickRole ? `@${options.currentQuickRole}` : undefined;
|
||||
this.#quickRoleItems = this.#buildQuickRoleItems(
|
||||
options.quickRoles ?? [],
|
||||
options.quickRoleOrder ?? options.quickRoles?.map(entry => entry.role) ?? [],
|
||||
);
|
||||
|
||||
this.#browser = new ModelBrowser(settings, {
|
||||
currentContextTokens: options.currentContextTokens,
|
||||
disableOverContext: true,
|
||||
emptyText: () => (this.#roleMode ? " No quick roles in the Ctrl+P cycle" : undefined),
|
||||
});
|
||||
this.#browser.onActivate = item => callbacks.onPick(item.model, item.selector);
|
||||
this.#browser.onActivate = item => {
|
||||
const quickRole = this.#quickRoles.get(item.selector);
|
||||
if (quickRole) {
|
||||
callbacks.onPickRole?.(quickRole);
|
||||
return;
|
||||
}
|
||||
callbacks.onPick(item.model, item.selector);
|
||||
};
|
||||
this.#browser.onCancel = () => callbacks.onCancel();
|
||||
this.#browser.setCurrentSelector(options.currentSelector);
|
||||
this.#browser.onQueryChange = query => this.#syncItemsForQuery(query);
|
||||
|
||||
// Hydrate synchronously from the current registry snapshot so the first
|
||||
// Enter after opening acts on cached models instead of being dropped
|
||||
@@ -97,7 +135,7 @@ export class ModelPickerComponent implements Component {
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
/** Rebuild items and role chips from the registry's in-memory state (selection is pinned by selector). */
|
||||
/** Rebuild model items and role chips from the registry's in-memory state. */
|
||||
#syncFromRegistryState(): void {
|
||||
let models: ReadonlyArray<Model>;
|
||||
if (this.#scopedModels.length > 0) {
|
||||
@@ -118,12 +156,51 @@ export class ModelPickerComponent implements Component {
|
||||
const roles = resolveRoleAssignments(this.#settings, allModels, models);
|
||||
const storage = this.#settings.getStorage();
|
||||
const mruOrder = storage?.getModelUsageOrder() ?? [];
|
||||
const items = buildBrowserItems(models);
|
||||
sortModelItems(items, { roles, mruOrder });
|
||||
this.#modelItems = buildBrowserItems(models);
|
||||
sortModelItems(this.#modelItems, { roles, mruOrder });
|
||||
this.#browser.setRoles(roles);
|
||||
this.#browser.setMruOrder(mruOrder);
|
||||
this.#browser.setPerfStats(storage?.getModelPerf() ?? new Map());
|
||||
this.#browser.setItems(items);
|
||||
this.#syncItemsForQuery(this.#browser.query, true);
|
||||
}
|
||||
|
||||
/** Build virtual `@role` rows, colored by their ctrl+p segment position. */
|
||||
#buildQuickRoleItems(
|
||||
quickRoles: ReadonlyArray<ResolvedRoleModel>,
|
||||
quickRoleOrder: ReadonlyArray<string>,
|
||||
): ModelBrowserItem[] {
|
||||
const order = quickRoleOrder.length > 0 ? quickRoleOrder : quickRoles.map(entry => entry.role);
|
||||
const palette = resolveSegmentPalette(order.length);
|
||||
return quickRoles.map((entry, index) => {
|
||||
const selector = `@${entry.role}`;
|
||||
this.#quickRoles.set(selector, entry);
|
||||
const orderIndex = order.indexOf(entry.role);
|
||||
return {
|
||||
provider: "",
|
||||
id: selector,
|
||||
model: entry.model,
|
||||
selector,
|
||||
labelColor: palette[(orderIndex >= 0 ? orderIndex : index) % palette.length],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Switch browser content only when a leading `@` changes the search mode. */
|
||||
#syncItemsForQuery(query: string, refresh = false): void {
|
||||
const roleMode = query.startsWith("@");
|
||||
const modeChanged = roleMode !== this.#roleMode;
|
||||
if (!modeChanged && !refresh) return;
|
||||
|
||||
this.#roleMode = roleMode;
|
||||
this.#browser.setShowProvider(!roleMode);
|
||||
this.#browser.setDisableOverContext(!roleMode);
|
||||
this.#browser.setPreserveQueryOrder(roleMode);
|
||||
const currentSelector = roleMode ? this.#currentQuickRoleSelector : this.#currentSelector;
|
||||
this.#browser.setCurrentSelector(currentSelector);
|
||||
this.#browser.setItems(roleMode ? this.#quickRoleItems : this.#modelItems);
|
||||
if (modeChanged && currentSelector) {
|
||||
this.#browser.selectSelector(currentSelector);
|
||||
}
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
@@ -141,7 +218,7 @@ export class ModelPickerComponent implements Component {
|
||||
const inner = Math.max(1, width - 4);
|
||||
const status = this.#configError
|
||||
? theme.fg("error", ` ${this.#configError}`)
|
||||
: theme.fg("muted", ` ${STATUS_HINT}`);
|
||||
: theme.fg("muted", ` ${this.#roleMode ? QUICK_ROLE_STATUS_HINT : STATUS_HINT}`);
|
||||
|
||||
const out: string[] = [];
|
||||
out.push(topBorder(width, "Switch Model"));
|
||||
@@ -149,7 +226,7 @@ export class ModelPickerComponent implements Component {
|
||||
for (const line of this.#browser.render(inner)) {
|
||||
out.push(row(line, width));
|
||||
}
|
||||
out.push(row(theme.fg("dim", FOOTER_HINT), width));
|
||||
out.push(row(theme.fg("dim", this.#roleMode ? QUICK_ROLE_FOOTER_HINT : FOOTER_HINT), width));
|
||||
out.push(bottomBorder(width));
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ import { ModelPickerComponent } from "../components/model-picker";
|
||||
import { OAuthSelectorComponent } from "../components/oauth-selector";
|
||||
import { PluginSelectorComponent } from "../components/plugin-selector";
|
||||
import { ResetUsageSelectorComponent } from "../components/reset-usage-selector";
|
||||
import { renderSegmentTrack } from "../components/segment-track";
|
||||
import { SessionSelectorComponent } from "../components/session-selector";
|
||||
import { SettingsSelectorComponent } from "../components/settings-selector";
|
||||
import { ToolExecutionComponent } from "../components/tool-execution";
|
||||
@@ -603,13 +604,14 @@ export class SelectorController {
|
||||
|
||||
/**
|
||||
* Compact session-only model picker (alt+p / `/switch`): a floating
|
||||
* bottom-anchored overlay over the transcript — just the model list, no
|
||||
* provider sidebar, no role management. The current model is highlighted
|
||||
* and preselected.
|
||||
* bottom-anchored overlay over the transcript. The current model is
|
||||
* highlighted and preselected; a leading `@` searches ctrl+p quick roles.
|
||||
*/
|
||||
#showModelPicker(): void {
|
||||
const currentContextTokens = this.ctx.session.getContextUsage()?.tokens ?? 0;
|
||||
const current = this.ctx.session.model;
|
||||
const quickRoleOrder = this.ctx.settings.get("cycleOrder");
|
||||
const quickRoleCycle = this.ctx.session.getRoleModelCycle(quickRoleOrder);
|
||||
let overlayHandle: OverlayHandle | undefined;
|
||||
let closed = false;
|
||||
const done = () => {
|
||||
@@ -638,11 +640,30 @@ export class SelectorController {
|
||||
this.ctx.showError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
},
|
||||
onPickRole: async entry => {
|
||||
try {
|
||||
await this.ctx.session.applyRoleModel(entry);
|
||||
this.ctx.statusLine.invalidate();
|
||||
this.ctx.updateEditorBorderColor();
|
||||
this.ctx.showModelCycleTrack(
|
||||
renderSegmentTrack(
|
||||
quickRoleOrder.map(role => ({ label: role })),
|
||||
quickRoleOrder.indexOf(entry.role),
|
||||
),
|
||||
);
|
||||
done();
|
||||
} catch (error) {
|
||||
this.ctx.showError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
},
|
||||
onCancel: done,
|
||||
},
|
||||
{
|
||||
currentContextTokens,
|
||||
currentSelector: current ? `${current.provider}/${current.id}` : undefined,
|
||||
quickRoles: quickRoleCycle?.models,
|
||||
quickRoleOrder,
|
||||
currentQuickRole: quickRoleCycle?.models[quickRoleCycle.currentIndex]?.role,
|
||||
},
|
||||
);
|
||||
overlayHandle = this.ctx.ui.showOverlay(picker, {
|
||||
|
||||
@@ -5,7 +5,9 @@ import { buildModel } from "@oh-my-pi/pi-catalog/build";
|
||||
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 { ModelPickerComponent, type ModelPickerOptions } from "@oh-my-pi/pi-coding-agent/modes/components/model-picker";
|
||||
import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
||||
import { resolveSegmentPalette } from "@oh-my-pi/pi-coding-agent/modes/components/segment-track";
|
||||
import { getThemeByName, setThemeInstance, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
||||
import type { ResolvedRoleModel } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
||||
import type { TUI } from "@oh-my-pi/pi-tui";
|
||||
|
||||
function normalize(lines: readonly string[]): string {
|
||||
@@ -43,6 +45,7 @@ interface RegistryOverrides {
|
||||
interface PickerHarness {
|
||||
picker: ModelPickerComponent;
|
||||
onPick: Mock<(model: Model, selector: string) => void>;
|
||||
onPickRole: Mock<(entry: ResolvedRoleModel) => void>;
|
||||
onCancel: Mock<() => void>;
|
||||
}
|
||||
|
||||
@@ -64,16 +67,17 @@ function createPicker(options: {
|
||||
} as unknown as ModelRegistry;
|
||||
const ui = { requestRender: vi.fn(), terminal: { rows: 40 } } as unknown as TUI;
|
||||
const onPick = vi.fn();
|
||||
const onPickRole = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
const picker = new ModelPickerComponent(
|
||||
ui,
|
||||
settings,
|
||||
registry,
|
||||
options.scoped ? modelsFn().map(model => ({ model })) : [],
|
||||
{ onPick, onCancel },
|
||||
{ onPick, onPickRole, onCancel },
|
||||
options.picker ?? {},
|
||||
);
|
||||
return { picker, onPick, onCancel };
|
||||
return { picker, onPick, onPickRole, onCancel };
|
||||
}
|
||||
|
||||
const DOWN = "\x1b[B";
|
||||
@@ -159,6 +163,37 @@ describe("ModelPicker", () => {
|
||||
expect(onPick.mock.calls[0]?.[0]?.id).toBe("bb-model");
|
||||
});
|
||||
|
||||
test("shows and applies ctrl+p quick roles when search starts with @", () => {
|
||||
const smol = makeModel("test", "smol-model");
|
||||
const slow = makeModel("test", "slow-model");
|
||||
const quickRoles: ResolvedRoleModel[] = [
|
||||
{ role: "smol", model: smol, explicitThinkingLevel: false },
|
||||
{ role: "slow", model: slow, explicitThinkingLevel: false },
|
||||
];
|
||||
const { picker, onPick, onPickRole } = createPicker({
|
||||
models: [smol, slow],
|
||||
scoped: true,
|
||||
picker: {
|
||||
quickRoles,
|
||||
quickRoleOrder: ["smol", "slow"],
|
||||
currentQuickRole: "slow",
|
||||
},
|
||||
});
|
||||
|
||||
picker.handleInput("@");
|
||||
const rendered = picker.render(220);
|
||||
const frame = rendered.join("\n");
|
||||
expect(normalize(rendered)).toContain("@smol");
|
||||
expect(normalize(rendered)).toContain("@slow");
|
||||
const palette = resolveSegmentPalette(2);
|
||||
expect(frame).toContain(`${theme.getFgAnsi(palette[0])}@smol`);
|
||||
expect(frame).toContain(`${theme.getFgAnsi(palette[1])}@slow`);
|
||||
|
||||
picker.handleInput("\n");
|
||||
expect(onPickRole).toHaveBeenCalledWith(quickRoles[1]);
|
||||
expect(onPick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Esc clears an active query first, then cancels", () => {
|
||||
const { picker, onCancel } = createPicker({ models: [makeModel("test", "test-model")], scoped: true });
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { SelectorController } from "@oh-my-pi/pi-coding-agent/modes/controllers/selector-controller";
|
||||
import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
||||
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
|
||||
import type { ResolvedRoleModel } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
||||
import { beginSettingsTest, restoreSettingsTestState, type SettingsTestState } from "./helpers/settings-test-state";
|
||||
|
||||
let settingsState: SettingsTestState | undefined;
|
||||
@@ -133,4 +134,89 @@ describe("selector setting side effects", () => {
|
||||
hub.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("applies an @ quick role through the role-switch session API", async () => {
|
||||
const testTheme = await getThemeByName("dark");
|
||||
if (!testTheme) throw new Error("Failed to load dark theme for quick-role picker test");
|
||||
setThemeInstance(testTheme);
|
||||
|
||||
const smol = buildModel({
|
||||
id: "smol-model",
|
||||
name: "smol-model",
|
||||
api: "ollama-chat",
|
||||
baseUrl: "https://example.com",
|
||||
reasoning: false,
|
||||
provider: "test",
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 1024,
|
||||
});
|
||||
const slow = buildModel({
|
||||
id: "slow-model",
|
||||
name: "slow-model",
|
||||
api: "ollama-chat",
|
||||
baseUrl: "https://example.com",
|
||||
reasoning: false,
|
||||
provider: "test",
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 1024,
|
||||
});
|
||||
const quickRoles: ResolvedRoleModel[] = [
|
||||
{ role: "smol", model: smol, explicitThinkingLevel: false },
|
||||
{ role: "slow", model: slow, explicitThinkingLevel: false },
|
||||
];
|
||||
const applyRoleModel = vi.fn(async () => {});
|
||||
const setModelTemporary = vi.fn(async () => {});
|
||||
const showModelCycleTrack = vi.fn();
|
||||
const showError = vi.fn();
|
||||
let picker: { handleInput(data: string): void } | undefined;
|
||||
const settings = Settings.isolated({ cycleOrder: ["smol", "slow"] });
|
||||
const controller = new SelectorController({
|
||||
ui: {
|
||||
requestRender: vi.fn(),
|
||||
setFocus: vi.fn(),
|
||||
showOverlay: vi.fn((component: unknown) => {
|
||||
picker = component as { handleInput(data: string): void };
|
||||
return { hide: vi.fn() };
|
||||
}),
|
||||
terminal: { rows: 40 },
|
||||
},
|
||||
editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] },
|
||||
editor: {},
|
||||
settings,
|
||||
session: {
|
||||
model: slow,
|
||||
modelRegistry: {
|
||||
getAll: () => [smol, slow],
|
||||
getAvailable: () => [smol, slow],
|
||||
getError: () => undefined,
|
||||
refresh: async () => {},
|
||||
},
|
||||
scopedModels: [{ model: smol }, { model: slow }],
|
||||
getContextUsage: () => undefined,
|
||||
getRoleModelCycle: () => ({ models: quickRoles, currentIndex: 1 }),
|
||||
applyRoleModel,
|
||||
setModelTemporary,
|
||||
},
|
||||
statusLine: { invalidate: vi.fn() },
|
||||
updateEditorBorderColor: vi.fn(),
|
||||
keybindings: { getKeys: () => [] },
|
||||
showModelCycleTrack,
|
||||
showError,
|
||||
} as unknown as InteractiveModeContext);
|
||||
|
||||
controller.showModelSelector({ temporaryOnly: true });
|
||||
if (!picker) throw new Error("Expected temporary model picker overlay");
|
||||
picker.handleInput("@");
|
||||
picker.handleInput("\n");
|
||||
await Promise.resolve();
|
||||
|
||||
expect(applyRoleModel).toHaveBeenCalledWith(quickRoles[1]);
|
||||
expect(setModelTemporary).not.toHaveBeenCalled();
|
||||
expect(showModelCycleTrack).toHaveBeenCalledTimes(1);
|
||||
expect(showError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user