feat(coding-agent): implemented custom role management in model hub

- Implemented custom role creation within the Model Hub, including a virtual row for direct initiation and name stripping.
- Added quick-switch cycle editing functionality with persistent ordering and live preview of role membership.
- Optimized sidebar scope navigation to mute empty entries and improve keyboard focus stability during search.
- Updated the Model Hub sidebar UI to prioritize Roles and included comprehensive tests for new navigation and management flows.
This commit is contained in:
can1357
2026-07-11 15:39:06 +02:00
parent 2081bae6a6
commit ab7b776f9a
4 changed files with 362 additions and 54 deletions
+3
View File
@@ -12,11 +12,14 @@
- Added rich interactive ask dialogs: a fixed-height bottom panel (sized at spawn from the tallest tab, clamped to 70% of the terminal) with question tabs, option previews, notes, per-option markers, and ask.enabled gating. Multi-select questions toggle with Space/Enter and confirm from the Submit tab; submitting an empty Other value unselects the custom answer. ([#4186](https://github.com/can1357/oh-my-pi/issues/4186))
- Added role un-assignment to the model hub: `x`/Backspace on a role row (or Enter on a chip already holding the model) clears the role back to auto-selection — previously only possible by editing config.
- Added a manual F5 provider refresh in the model hub; providers now auto-refresh at most once per application lifetime instead of on every visit.
- Added custom role creation to the Model Hub roles view (`+ New role…` / `n`): name the role and jump straight into assigning its model.
- Added quick-switch (ctrl+p) cycle editing to the Model Hub roles view: `c` toggles a role's membership, `[`/`]` (or Shift+↑/↓) reorder it, with a live segment-track preview of the resulting cycle.
### Changed
- Updated Model Hub sidebar to float providers with search matches to the top while searching
- Added spatial navigation between the Model Hub sidebar and model list using left/right arrow keys
- Model Hub sidebar now leads with Roles above Recent, and hopping scopes with ↑/↓ no longer captures arrow focus when passing the Roles entry (diving into role rows is explicit via Enter, →, or click); while searching, the hop also skips Roles and an empty Recent, and the Recent/All annotations show live match counts.
- Removed "Chat about this" option from question dialogs
- Redesigned ask dialogs with fixed height, removed explicit Next button, and simplified headers
- Rewrote the task tool prompt for the new wire schema and to push callers toward the most specific agent type: read-only research is directed to `agent: "scout"`, and omitting `agent` is framed as an explicit decision that no listed specialist fits.
@@ -17,6 +17,8 @@ import {
type Component,
extractPrintableText,
fuzzyFilter,
getKeybindings,
Input,
matchesKey,
routeSgrMouseInput,
type SgrMouseEvent,
@@ -45,6 +47,7 @@ import {
thinkingLevelGlyph,
} from "./model-browser";
import { bottomBorder, dividerSplit, row, splitBodyWidth, splitRow, topBorderSplit } from "./overlay-box";
import { renderSegmentTrack } from "./segment-track";
/** `roles` is the full /models hub; `pick` is a one-shot session/embedded picker. */
export type ModelHubMode = "roles" | "pick";
@@ -72,6 +75,8 @@ export interface ModelHubCallbacks {
onPick?: (model: Model, selector: string) => void;
/** Locked provider activation: forward to the /login flow. */
onLoginRequest?: (providerId: string) => void;
/** Persist a new quick-switch cycle order (the ctrl+p role cycle). */
onCycleOrderChange?: (order: string[]) => void;
onCancel: () => void;
}
@@ -107,15 +112,21 @@ interface StripChip {
thinkingLevel?: ConfiguredThinkingLevel;
}
interface StripState {
kind: "role" | "thinking";
item: ModelBrowserItem;
role?: string;
chips: StripChip[];
index: number;
/** Where to land when a thinking strip closes. */
returnToRoles: boolean;
}
type StripState =
| {
kind: "role" | "thinking";
item: ModelBrowserItem;
role?: string;
chips: StripChip[];
index: number;
/** Where to land when a thinking strip closes. */
returnToRoles: boolean;
}
| {
/** Footer text input naming a new custom role. */
kind: "roleName";
input: Input;
};
/** Recorded chip hit-range on the footer row (columns relative to frame col 0). */
interface ChipRange {
@@ -168,6 +179,9 @@ export class ModelHubComponent implements Component {
#fixedEntries: SidebarEntry[] = [];
#unlockedProviderEntries: SidebarEntry[] = [];
#lockedProviderEntries: SidebarEntry[] = [];
/** Fuzzy match totals while searching: recent-scope hits and overall hits. */
#recentSearchCount = 0;
#searchTotal = 0;
#activeEntryId = "all";
#sidebarScroll = 0;
#sidebarHover: number | null = null;
@@ -434,14 +448,9 @@ export class ModelHubComponent implements Component {
if (assignment && !assignment.autoSelected) assignedCount++;
}
const fixed: SidebarEntry[] = [
{
id: "recent",
kind: "recent",
label: "Recent",
annotation: this.#recentItems.length > 0 ? String(this.#recentItems.length) : "—",
},
];
// Roles leads the fixed section so downward hops from Recent head into
// model scopes instead of being captured by the roles view.
const fixed: SidebarEntry[] = [];
if (this.#mode === "roles") {
fixed.push({
id: "roles",
@@ -450,6 +459,12 @@ export class ModelHubComponent implements Component {
annotation: `${assignedCount}/${visibleRoles.length}`,
});
}
fixed.push({
id: "recent",
kind: "recent",
label: "Recent",
annotation: this.#recentItems.length > 0 ? String(this.#recentItems.length) : "—",
});
fixed.push({ id: "all", kind: "all", label: "All models", annotation: String(availableModels.length) });
this.#fixedEntries = fixed;
@@ -503,9 +518,10 @@ export class ModelHubComponent implements Component {
this.#activeEntryId = id;
this.#applyScope();
const entry = this.#activeEntry();
// The Roles view IS its rows — land with row navigation active; every
// other scope starts in provider-hop mode.
this.#focus = entry.kind === "roles" && this.#assigningRole === null ? "list" : "scope";
// Hops must never steal arrow focus: landing on a scope keeps provider
// navigation active. Diving into the roles rows is explicit (Enter, →,
// or a click on the Roles entry).
this.#focus = "scope";
if (entry.kind === "provider" && !entry.locked) {
this.#scheduleProviderRefresh(entry.providerId ?? "");
}
@@ -534,7 +550,7 @@ export class ModelHubComponent implements Component {
}
case "roles":
this.#roleIds = this.#visibleRoleIds();
this.#roleIndex = Math.min(this.#roleIndex, Math.max(0, this.#roleIds.length - 1));
this.#roleIndex = Math.min(this.#roleIndex, Math.max(0, this.#rolesRowCount - 1));
break;
default:
this.#browser.setShowProvider(true);
@@ -566,6 +582,12 @@ export class ModelHubComponent implements Component {
for (const item of matches) {
counts.set(item.provider, (counts.get(item.provider) ?? 0) + 1);
}
const recentSelectors = new Set(this.#recentItems.map(item => item.selector));
this.#recentSearchCount = matches.reduce(
(total, item) => total + (recentSelectors.has(item.selector) ? 1 : 0),
0,
);
this.#searchTotal = matches.length;
this.#searchCounts = counts;
this.#composeEntries();
const entry = this.#activeEntry();
@@ -578,10 +600,17 @@ export class ModelHubComponent implements Component {
}
}
/** Entries the scope hop skips: separators, and while searching, providers without matches. */
/**
* Entries the scope hop skips: separators always; while searching, also
* the Roles view (not a model scope), an empty Recent, locked providers,
* and providers without matches.
*/
#isHopSkipped(entry: SidebarEntry): boolean {
if (entry.kind === "separator") return true;
if (this.#searchCounts && entry.kind === "provider") {
if (!this.#searchCounts) return false;
if (entry.kind === "roles") return true;
if (entry.kind === "recent") return this.#recentSearchCount === 0;
if (entry.kind === "provider") {
if (entry.locked) return true;
return (this.#searchCounts.get(entry.providerId ?? "") ?? 0) === 0;
}
@@ -802,12 +831,13 @@ export class ModelHubComponent implements Component {
this.#chipRanges = [];
if (strip?.kind === "thinking" && strip.returnToRoles && this.#mode === "roles") {
this.#setActiveEntry("roles");
this.#focus = "list";
}
}
#activateStripChip(): void {
const strip = this.#strip;
if (!strip) return;
if (!strip || strip.kind === "roleName") return;
const chip = strip.chips[strip.index];
if (!chip) return;
switch (chip.action) {
@@ -844,9 +874,8 @@ export class ModelHubComponent implements Component {
}
}
#startAssignFromRolesView(): void {
const role = this.#roleIds[this.#roleIndex];
if (!role) return;
/** Switch the body into assign mode for `role`: full catalog, cleared query, current model preselected. */
#startAssign(role: string): void {
this.#assigningRole = role;
this.#focus = "scope";
this.#browser.setShowProvider(true);
@@ -863,9 +892,63 @@ export class ModelHubComponent implements Component {
this.#browser.setQuery("");
if (this.#mode === "roles") {
this.#setActiveEntry("roles");
this.#focus = "list";
}
}
// ═══════════════════════════════════════════════════════════════════════
// Quick-switch cycle (ctrl+p) editing
// ═══════════════════════════════════════════════════════════════════════
#cycleOrder(): string[] {
try {
return [...this.#settings.get("cycleOrder")];
} catch {
return [];
}
}
/** Toggle `role`'s membership in the quick-switch cycle (appended at the end). */
#toggleCycleMembership(role: string): void {
const order = this.#cycleOrder();
const index = order.indexOf(role);
if (index >= 0) {
order.splice(index, 1);
} else {
order.push(role);
}
this.#callbacks.onCycleOrderChange?.(order);
this.#refreshAfterMutation();
}
/** Move `role` one slot earlier/later within the cycle order. */
#moveCycleMembership(role: string, delta: -1 | 1): void {
const order = this.#cycleOrder();
const index = order.indexOf(role);
const target = index + delta;
if (index < 0 || target < 0 || target >= order.length) return;
[order[index], order[target]] = [order[target], order[index]];
this.#callbacks.onCycleOrderChange?.(order);
this.#refreshAfterMutation();
}
/** Open the footer name input that creates a new custom role. */
#openRoleNameStrip(): void {
this.#strip = { kind: "roleName", input: new Input() };
}
/** Validate and commit the new-role name: jump straight into assigning it. */
#submitRoleName(): void {
const strip = this.#strip;
if (strip?.kind !== "roleName") return;
const name = strip.input.getValue().trim();
if (!/^[a-zA-Z][\w-]*$/.test(name)) return;
if (this.#visibleRoleIds().includes(name)) return;
this.#strip = null;
this.#chipRanges = [];
this.#startAssign(name);
}
// ═══════════════════════════════════════════════════════════════════════
// Input
// ═══════════════════════════════════════════════════════════════════════
@@ -962,6 +1045,14 @@ export class ModelHubComponent implements Component {
this.#closeStrip();
return;
}
if (strip.kind === "roleName") {
if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
this.#submitRoleName();
return;
}
strip.input.handleInput(data);
return;
}
if (matchesKey(data, "left") || matchesKey(data, "up") || matchesKey(data, "shift+tab")) {
strip.index = (strip.index - 1 + strip.chips.length) % strip.chips.length;
return;
@@ -994,32 +1085,74 @@ export class ModelHubComponent implements Component {
}
}
/** Row count of the roles view: every visible role plus the trailing "+ New role…" row. */
get #rolesRowCount(): number {
return this.#roleIds.length + 1;
}
#handleRolesViewInput(data: string): void {
// Scope focus treats the roles view as a preview: Enter/Space dives
// into the rows, everything else is inert (arrows already hop).
if (this.#focus === "scope") {
if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n" || matchesKey(data, "space")) {
this.#focus = "list";
}
return;
}
const rowCount = Math.max(1, this.#rolesRowCount);
if (matchesSelectUp(data)) {
this.#roleIndex = (this.#roleIndex - 1 + this.#roleIds.length) % Math.max(1, this.#roleIds.length);
this.#roleIndex = (this.#roleIndex - 1 + rowCount) % rowCount;
return;
}
if (matchesSelectDown(data)) {
this.#roleIndex = (this.#roleIndex + 1) % Math.max(1, this.#roleIds.length);
this.#roleIndex = (this.#roleIndex + 1) % rowCount;
return;
}
const role = this.#roleIds[this.#roleIndex];
if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
this.#startAssignFromRolesView();
if (role) {
this.#startAssign(role);
} else {
// The virtual "+ New role…" row.
this.#openRoleNameStrip();
}
return;
}
if (matchesKey(data, "backspace") || matchesKey(data, "delete")) {
const role = this.#roleIds[this.#roleIndex];
if (role) this.#unassignRole(role);
return;
}
// Cycle reordering: [ / shift+↑ moves the role earlier, ] / shift+↓ later.
if (matchesKey(data, "shift+up")) {
if (role) this.#moveCycleMembership(role, -1);
return;
}
if (matchesKey(data, "shift+down")) {
if (role) this.#moveCycleMembership(role, 1);
return;
}
const printable = extractPrintableText(data);
if (printable === "x") {
const role = this.#roleIds[this.#roleIndex];
if (role) this.#unassignRole(role);
return;
}
if (printable === "c") {
if (role) this.#toggleCycleMembership(role);
return;
}
if (printable === "[") {
if (role) this.#moveCycleMembership(role, -1);
return;
}
if (printable === "]") {
if (role) this.#moveCycleMembership(role, 1);
return;
}
if (printable === "n") {
this.#openRoleNameStrip();
return;
}
if (printable === "t") {
const role = this.#roleIds[this.#roleIndex];
const assignment = role ? this.#roles[role] : undefined;
if (role && assignment) {
const item: ModelBrowserItem = {
@@ -1058,10 +1191,11 @@ export class ModelHubComponent implements Component {
// Footer strip chips.
if (event.row === this.#footerRow && this.#strip) {
if (event.leftClick) {
const strip = this.#strip;
if (event.leftClick && strip.kind !== "roleName") {
for (const range of this.#chipRanges) {
if (event.col >= range.start && event.col < range.end) {
this.#strip.index = range.index;
strip.index = range.index;
this.#activateStripChip();
return true;
}
@@ -1075,7 +1209,7 @@ export class ModelHubComponent implements Component {
this.#moveSidebar(event.wheel);
} else if (overBody) {
if (entry.kind === "roles" && this.#assigningRole === null) {
const count = Math.max(1, this.#roleIds.length);
const count = Math.max(1, this.#rolesRowCount);
this.#roleIndex = (this.#roleIndex + event.wheel + count) % count;
} else if (this.#isBrowserView(entry)) {
this.#browser.routeMouse(event, bodyLine);
@@ -1088,7 +1222,7 @@ export class ModelHubComponent implements Component {
this.#sidebarHover = overSidebar ? this.#sidebarEntryIndexAt(contentLine) : null;
if (overBody && entry.kind === "roles" && this.#assigningRole === null) {
const roleLine = bodyLine - this.#rolesRowStart;
this.#roleHover = roleLine >= 0 && roleLine < this.#roleIds.length ? roleLine : null;
this.#roleHover = roleLine >= 0 && roleLine < this.#rolesRowCount ? roleLine : null;
} else {
this.#roleHover = null;
if (overBody && this.#isBrowserView(entry)) {
@@ -1107,6 +1241,8 @@ export class ModelHubComponent implements Component {
const already = clicked.id === this.#activeEntryId;
if (clicked.kind === "roles") this.#assigningRole = null;
this.#setActiveEntry(clicked.id);
// A click on Roles is a deliberate dive into the rows.
if (clicked.kind === "roles") this.#focus = "list";
if (already && clicked.kind === "provider" && clicked.locked) {
this.#requestLogin(clicked);
}
@@ -1116,10 +1252,16 @@ export class ModelHubComponent implements Component {
if (overBody) {
if (entry.kind === "roles" && this.#assigningRole === null) {
this.#focus = "list";
const roleLine = bodyLine - this.#rolesRowStart;
if (roleLine >= 0 && roleLine < this.#roleIds.length) {
if (roleLine >= 0 && roleLine < this.#rolesRowCount) {
if (roleLine === this.#roleIndex) {
this.#startAssignFromRolesView();
const role = this.#roleIds[roleLine];
if (role) {
this.#startAssign(role);
} else {
this.#openRoleNameStrip();
}
} else {
this.#roleIndex = roleLine;
}
@@ -1177,13 +1319,19 @@ export class ModelHubComponent implements Component {
const active = entry.id === this.#activeEntryId;
const hovered = i === this.#sidebarHover;
const searching = this.#searchCounts !== null;
const matchCount =
searching && entry.kind === "provider" && !entry.locked
? (this.#searchCounts?.get(entry.providerId ?? "") ?? 0)
: undefined;
// While searching, providers without matches gray out alongside
// locked ones (the scope hop skips both).
const muted = entry.locked || matchCount === 0;
let matchCount: number | undefined;
if (searching) {
if (entry.kind === "provider" && !entry.locked) {
matchCount = this.#searchCounts?.get(entry.providerId ?? "") ?? 0;
} else if (entry.kind === "recent") {
matchCount = this.#recentSearchCount;
} else if (entry.kind === "all") {
matchCount = this.#searchTotal;
}
}
// While searching, entries the hop skips gray out: locked and
// zero-match providers, an empty Recent, and the Roles view.
const muted = entry.locked || matchCount === 0 || (searching && entry.kind === "roles");
const cursor = active
? this.#focus === "scope"
? theme.fg("accent", theme.nav.cursor)
@@ -1279,7 +1427,8 @@ export class ModelHubComponent implements Component {
tagWidth = Math.max(tagWidth, visibleWidth(info.tag ?? info.name ?? role));
}
for (let i = 0; i < this.#roleIds.length && lines.length < rows - 1; i++) {
const cycleOrder = this.#cycleOrder();
for (let i = 0; i < this.#roleIds.length && lines.length < rows - 3; i++) {
const role = this.#roleIds[i];
const info = getRoleInfo(role, this.#settings);
const assignment = this.#roles[role];
@@ -1311,11 +1460,16 @@ export class ModelHubComponent implements Component {
value = theme.fg("dim", "—");
}
// Quick-cycle membership badge (`⟳2` = second stop of the ctrl+p cycle).
const cycleIndex = cycleOrder.indexOf(role);
const cycleStyled = cycleIndex >= 0 ? theme.fg("accent", `${theme.icon.loop}${cycleIndex + 1}`) : "";
let line = ` ${cursor} ${dot} ${tagStyled} ${value}`;
const levelWidth = visibleWidth(levelStyled);
const right = [levelStyled, cycleStyled].filter(part => part.length > 0).join(" ");
const rightWidth = visibleWidth(right);
const lineWidth = visibleWidth(line);
if (levelWidth > 0 && lineWidth + levelWidth + 2 <= width) {
line = `${line}${" ".repeat(width - lineWidth - levelWidth - 1)}${levelStyled}`;
if (rightWidth > 0 && lineWidth + rightWidth + 2 <= width) {
line = `${line}${" ".repeat(width - lineWidth - rightWidth - 1)}${right}`;
} else {
line = truncateToWidth(line, width);
}
@@ -1325,7 +1479,39 @@ export class ModelHubComponent implements Component {
lines.push(line);
}
while (lines.length < rows) lines.push("");
// Trailing virtual row: create a custom role.
if (lines.length < rows - 2) {
const newRoleIndex = this.#roleIds.length;
const selected = this.#roleIndex === newRoleIndex;
const hovered = this.#roleHover === newRoleIndex;
const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " ";
let line = ` ${cursor} ${theme.fg(selected ? "accent" : "dim", "+ New role…")}`;
line = truncateToWidth(line, width);
if (hovered && !selected) {
line = theme.bg("selectedBg", line);
}
lines.push(line);
}
// Live preview of the quick-switch cycle, rendered with the exact
// segment track the ctrl+p status uses; the selected role's chip fills.
while (lines.length < rows - 1) lines.push("");
if (rows >= 2) {
const cycleKey = getKeybindings().getKeys("app.model.cycleForward")[0] ?? "ctrl+p";
if (cycleOrder.length > 0) {
const activeIndex = cycleOrder.indexOf(this.#roleIds[this.#roleIndex] ?? "");
const track = renderSegmentTrack(
cycleOrder.map(role => ({ label: role })),
activeIndex,
);
lines[rows - 1] = truncateToWidth(` ${theme.fg("dim", `${cycleKey} cycle:`)} ${track}`, width);
} else {
lines[rows - 1] = truncateToWidth(
theme.fg("dim", ` ${cycleKey} cycle is empty — press c on a role to add it`),
width,
);
}
}
return lines;
}
@@ -1368,6 +1554,9 @@ export class ModelHubComponent implements Component {
#footerHint(): string {
const strip = this.#strip;
if (strip) {
if (strip.kind === "roleName") {
return "Enter create + pick model · Esc cancel";
}
return strip.kind === "role"
? "←/→ choose · Enter assign/clear · Esc cancel"
: "←/→ thinking level · Enter apply · Esc keep";
@@ -1378,7 +1567,7 @@ export class ModelHubComponent implements Component {
const entry = this.#activeEntry();
if (entry.kind === "roles") {
return this.#focus === "list"
? "↑/↓ roles · Enter pick model · x clear · t thinking · ← providers · Esc close"
? "↑/↓ roles · Enter pick · x clear · t thinking · c cycle · [/] reorder · n new · ← providers"
: "↑/↓ providers · → roles · Esc close";
}
if (entry.kind === "provider" && entry.locked) {
@@ -1400,6 +1589,13 @@ export class ModelHubComponent implements Component {
return truncateToWidth(theme.fg("dim", this.#footerHint()), width);
}
if (strip.kind === "roleName") {
const label = theme.fg("accent", "New role name:");
const inputWidth = Math.max(8, Math.min(32, width - visibleWidth("New role name:") - 24));
const inputLine = strip.input.render(inputWidth)[0] ?? "";
return truncateToWidth(`${label} ${inputLine} ${theme.fg("dim", "(letters, digits, - and _)")}`, width);
}
const prefix =
strip.kind === "role"
? `${theme.fg("accent", strip.item.id)}${theme.fg("dim", " →")} `
@@ -698,6 +698,16 @@ export class SelectorController {
done();
void this.#loginThenReopenModelHub(providerId);
},
onCycleOrderChange: order => {
try {
this.ctx.settings.set("cycleOrder", order);
this.ctx.showStatus(
order.length > 0 ? `Quick-switch cycle: ${order.join(" → ")}` : "Quick-switch cycle cleared",
);
} catch (error) {
this.ctx.showError(error instanceof Error ? error.message : String(error));
}
},
onCancel: () => done(),
},
{
+101 -2
View File
@@ -110,6 +110,7 @@ function createHub(options: {
onUnassign: options.callbacks?.onUnassign ?? onUnassign,
onPick: options.callbacks?.onPick ?? onPick,
onLoginRequest: options.callbacks?.onLoginRequest ?? onLoginRequest,
onCycleOrderChange: options.callbacks?.onCycleOrderChange,
onCancel: options.callbacks?.onCancel ?? onCancel,
},
options.hub,
@@ -188,7 +189,8 @@ describe("ModelHub", () => {
const { hub } = createHub({ models: [model], scoped: true, settings });
installTestTheme();
hub.handleInput(UP); // All models → Roles
hub.handleInput(UP); // All models → Recent
hub.handleInput(UP); // Recent → Roles (now leads the sidebar)
const lines = hub.render(220).map(line => stripVTControlCharacters(line));
const defaultRow = lines.find(line => line.includes("DEFAULT"));
const smolRow = lines.find(line => line.includes("SMOL"));
@@ -213,7 +215,9 @@ describe("ModelHub", () => {
});
installTestTheme();
hub.handleInput(UP); // Roles view
hub.handleInput(UP);
hub.handleInput(UP); // Roles view (top of the sidebar)
hub.handleInput("\n"); // dive into the role rows
hub.handleInput(DOWN); // default → smol row
hub.handleInput("x");
@@ -227,6 +231,101 @@ describe("ModelHub", () => {
});
});
describe("hop focus stability", () => {
test("hopping onto Roles keeps provider navigation instead of capturing the arrows", () => {
const model = makeModel("prov-a", "model-a");
const { hub } = createHub({ models: [model] });
installTestTheme();
hub.handleInput(UP);
hub.handleInput(UP); // All models → Recent → Roles
// The roles view shows as a preview, but arrows keep hopping.
expect(footerLine(hub.render(220))).toContain("→ roles");
hub.handleInput(DOWN); // continues to Recent — not a role row
expect(normalize(hub.render(220))).toContain("Recently used");
});
test("while searching, the hop skips Roles and an empty Recent", () => {
const model = makeModel("prov-a", "target-model");
const { hub } = createHub({ models: [model] });
installTestTheme();
for (const ch of "target") hub.handleInput(ch);
hub.handleInput(UP); // skips Recent (0 recent hits) and Roles → wraps to prov-a
expect(normalize(hub.render(220))).toContain("prov-a ·");
expect(footerLine(hub.render(220))).not.toContain("→ roles");
});
});
describe("quick-switch cycle and custom roles", () => {
test("c toggles cycle membership, [ reorders, and the preview tracks the order", () => {
const model = makeModel("test", "cycle-model");
const settings = Settings.isolated({});
const changes: string[][] = [];
const { hub } = createHub({
models: [model],
scoped: true,
settings,
callbacks: {
onCycleOrderChange: order => {
changes.push([...order]);
settings.set("cycleOrder", order);
},
},
});
installTestTheme();
hub.handleInput(UP);
hub.handleInput(UP); // Roles view
hub.handleInput("\n"); // dive into rows; cursor on DEFAULT
// Default cycle is [smol, default, slow]: c removes default…
hub.handleInput("c");
expect(changes[0]).toEqual(["smol", "slow"]);
// …c again re-appends it at the end…
hub.handleInput("c");
expect(changes[1]).toEqual(["smol", "slow", "default"]);
// …and [ moves it one slot earlier.
hub.handleInput("[");
expect(changes[2]).toEqual(["smol", "default", "slow"]);
// The preview line renders the resulting ctrl+p track in order.
const preview = hub
.render(220)
.map(line => stripVTControlCharacters(line))
.find(line => line.includes("cycle:"));
expect(preview).toBeDefined();
const previewText = preview ?? "";
expect(previewText.indexOf("smol")).toBeGreaterThan(-1);
expect(previewText.indexOf("smol")).toBeLessThan(previewText.indexOf("default"));
expect(previewText.indexOf("default")).toBeLessThan(previewText.indexOf("slow"));
});
test("the + New role row names a custom role and jumps into assigning it", () => {
const model = makeModel("test", "reviewer-model");
const { hub, onAssign } = createHub({ models: [model], scoped: true });
installTestTheme();
hub.handleInput(UP);
hub.handleInput(UP); // Roles view
hub.handleInput("\n"); // dive into rows
hub.handleInput(UP); // wraps to the trailing "+ New role…" row
hub.handleInput("\n");
expect(footerLine(hub.render(220))).toContain("New role name:");
for (const ch of "reviewer") hub.handleInput(ch);
hub.handleInput("\n");
expect(normalize(hub.render(220))).toContain("Assigning reviewer");
hub.handleInput("\n"); // pick the sole model for the new role
expect(onAssign).toHaveBeenCalledTimes(1);
const call = onAssign.mock.calls[0];
expect(call?.[1]).toBe("reviewer");
expect(call?.[3]).toBe("test/reviewer-model");
expect(call?.[4]).toBe("modelRole");
});
});
describe("assignment strips", () => {
test("Enter opens the role strip; assigning fires onAssign and opens the thinking strip", () => {
const model = getBundledModel("openai", "gpt-5.5");