diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index fa441dfcc..621ab0ba9 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/modes/components/model-browser.ts b/packages/coding-agent/src/modes/components/model-browser.ts index 84ae9f3aa..a08fc3eba 100644 --- a/packages/coding-agent/src/modes/components/model-browser.ts +++ b/packages/coding-agent/src/modes/components/model-browser.ts @@ -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(); - 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(); + 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 diff --git a/packages/coding-agent/src/modes/components/model-picker.ts b/packages/coding-agent/src/modes/components/model-picker.ts index be284792d..71301c276 100644 --- a/packages/coding-agent/src/modes/components/model-picker.ts +++ b/packages/coding-agent/src/modes/components/model-picker.ts @@ -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; + /** Complete ctrl+p order, including unavailable roles, to preserve segment colors. */ + quickRoleOrder?: ReadonlyArray; + /** 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; #browser: ModelBrowser; #configError: string | undefined; + #currentSelector: string | undefined; + #currentQuickRoleSelector: string | undefined; + #modelItems: ModelBrowserItem[] = []; + #quickRoleItems: ModelBrowserItem[] = []; + #quickRoles = new Map(); + #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; 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, + quickRoleOrder: ReadonlyArray, + ): 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; } diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 1b983cc92..adbd74d38 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -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, { diff --git a/packages/coding-agent/test/model-picker.test.ts b/packages/coding-agent/test/model-picker.test.ts index 20de0b766..b29348ddb 100644 --- a/packages/coding-agent/test/model-picker.test.ts +++ b/packages/coding-agent/test/model-picker.test.ts @@ -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 }); diff --git a/packages/coding-agent/test/selector-settings-side-effects.test.ts b/packages/coding-agent/test/selector-settings-side-effects.test.ts index a44df7f35..15c9f3531 100644 --- a/packages/coding-agent/test/selector-settings-side-effects.test.ts +++ b/packages/coding-agent/test/selector-settings-side-effects.test.ts @@ -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(); + }); });