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:
can1357
2026-07-13 03:42:38 +02:00
parent 0856055dfe
commit af7345e87e
6 changed files with 273 additions and 33 deletions
+1
View File
@@ -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();
});
});