@dieulc/workflow 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +45 -2
  2. package/index.ts +1824 -114
  3. package/package.json +3 -2
  4. package/profiles.ts +617 -0
package/index.ts CHANGED
@@ -125,6 +125,24 @@ import {
125
125
  type RoleModel,
126
126
  type ThinkingLevel,
127
127
  } from "./roles.ts";
128
+ import {
129
+ defaultProfileConfig,
130
+ loadProfilesDetailed,
131
+ profilesFingerprint,
132
+ saveProfiles,
133
+ getProfile,
134
+ formatProfilesForDisplay,
135
+ describeProfile,
136
+ validateProfileName,
137
+ applyProfileOverlay,
138
+ missingProfileRoles,
139
+ readDefaultProfileSetting,
140
+ writeDefaultProfileSetting,
141
+ defaultProfileSettingPaths,
142
+ PROFILE_ROLES,
143
+ type ProfileConfig,
144
+ type Profile,
145
+ } from "./profiles.ts";
128
146
  import {
129
147
  applySubmittedPlan,
130
148
  formatReviewStatusLines,
@@ -473,6 +491,232 @@ export default function workflowExtension(pi: ExtensionAPI) {
473
491
  void _e;
474
492
  }
475
493
  }
494
+
495
+ // ── Model profiles state (session-scoped activation) ───────────
496
+ let profilesConfig: ProfileConfig = defaultProfileConfig();
497
+ let profilesLoadedFor: string | undefined;
498
+ let profilesLoadedFingerprint: string | undefined;
499
+ /** Reason the last load skipped the user file — only corrupt/legacy warns. */
500
+ let profilesFallbackReason: string | undefined;
501
+ let profilesFallbackSeverity: "info" | "warning" | undefined;
502
+ let profilesFallbackNotified = false;
503
+ /** Active profile for THIS session — never global (concurrent sessions each pick their own). */
504
+ let activeProfileName: string | null = null;
505
+ /** True once the user picked explicitly in this session; otherwise workflow.defaultProfile applies. */
506
+ let profileExplicit = false;
507
+ /** Cache for the effective (profile-overlaid) roles — see effectiveRoles(). */
508
+ let effectiveRolesCache: {
509
+ base: RoleConfig;
510
+ profile: string | null;
511
+ roles: Role[];
512
+ } | null = null;
513
+
514
+ function invalidateEffectiveRoles(): void {
515
+ effectiveRolesCache = null;
516
+ }
517
+
518
+ /**
519
+ * The single profiles accessor, mirroring ensureRoleConfig: reloads when the
520
+ * working directory changes or either profiles.json changes on disk (mtime/
521
+ * size), so a `/profile` edit made in another window is picked up without a
522
+ * `/reload`. Non-destructive: a bad or missing file is never overwritten
523
+ * (see loadProfilesDetailed). A profile activated in this session that has
524
+ * vanished from disk is deactivated here (with the fallback warning).
525
+ */
526
+ function ensureProfilesConfig(cwd?: string): ProfileConfig {
527
+ const key = cwd ?? "";
528
+ const fingerprint = profilesFingerprint(key);
529
+ if (
530
+ profilesLoadedFor === key &&
531
+ profilesLoadedFingerprint === fingerprint
532
+ ) {
533
+ return profilesConfig;
534
+ }
535
+ const loaded = loadProfilesDetailed(key);
536
+ profilesConfig = loaded.config;
537
+ profilesLoadedFor = key;
538
+ profilesLoadedFingerprint = fingerprint;
539
+ profilesFallbackReason = loaded.usedDefaults ? loaded.reason : undefined;
540
+ profilesFallbackSeverity = loaded.usedDefaults
541
+ ? loaded.severity
542
+ : undefined;
543
+ profilesFallbackNotified = false;
544
+ invalidateEffectiveRoles();
545
+ // A profile selected in this session may have been deleted/renamed in
546
+ // another window — fall back to the base roles flow.
547
+ if (activeProfileName && !getProfile(profilesConfig, activeProfileName)) {
548
+ activeProfileName = null;
549
+ }
550
+ return profilesConfig;
551
+ }
552
+
553
+ /**
554
+ * Surface a profiles load problem once per load. A MISSING file is the
555
+ * normal "feature unused" state (info — never notified); only corrupt or
556
+ * incompatible files warn.
557
+ */
558
+ function notifyProfilesFallback(ctx?: ExtensionContext): void {
559
+ if (!profilesFallbackReason || profilesFallbackNotified) return;
560
+ profilesFallbackNotified = true;
561
+ if (profilesFallbackSeverity !== "warning") return;
562
+ try {
563
+ ctx?.ui.notify(`Profiles: ${profilesFallbackReason}`, "warning");
564
+ } catch (_e) {
565
+ void _e;
566
+ }
567
+ }
568
+
569
+ /** Resolve the session's active profile object (null when none/unknown). */
570
+ function resolveActiveProfile(): Profile | null {
571
+ if (!activeProfileName) return null;
572
+ return getProfile(profilesConfig, activeProfileName) ?? null;
573
+ }
574
+
575
+ /**
576
+ * The effective roles for THIS session: the file-backed base roles with the
577
+ * active profile's model overrides applied (see applyProfileOverlay). All
578
+ * model-RESOLUTION sites read through this; all EDITING sites (/role, role
579
+ * picker) keep using the base `roleConfig`. With no profile active the
580
+ * overlay is a shallow copy — behaviour is identical to the roles.json flow.
581
+ * Cache is keyed by the base config object identity (ensureRoleConfig
582
+ * reassigns it on file changes) + the active profile name, and invalidated
583
+ * by ensureProfilesConfig / setActiveProfile.
584
+ */
585
+ function effectiveRoles(): Role[] {
586
+ const base = roleConfig;
587
+ const profileName = activeProfileName;
588
+ const cached = effectiveRolesCache;
589
+ if (cached && cached.base === base && cached.profile === profileName) {
590
+ return cached.roles;
591
+ }
592
+ const roles = applyProfileOverlay(base.roles, resolveActiveProfile());
593
+ effectiveRolesCache = { base, profile: profileName, roles };
594
+ return roles;
595
+ }
596
+
597
+ /** getRole over the effective (profile-overlaid) roles. */
598
+ function getEffectiveRole(name: string): Role | undefined {
599
+ return getRole({ ...roleConfig, roles: effectiveRoles() }, name);
600
+ }
601
+
602
+ /** Footer label for the active profile ("" when none). */
603
+ function activeProfileLabel(): string {
604
+ return activeProfileName ? `⚡ ${activeProfileName}` : "";
605
+ }
606
+
607
+ /**
608
+ * /role display: the base roles (what /role edits) plus, when a profile is
609
+ * active, an explicit section naming the profile and its model overrides.
610
+ * Without a profile this is exactly the plain roles display.
611
+ */
612
+ function formatRolesDisplayWithProfile(): string {
613
+ const base = formatRolesForDisplay(roleConfig);
614
+ const profile = resolveActiveProfile();
615
+ if (!profile) return base;
616
+ const lines: string[] = [
617
+ base,
618
+ "",
619
+ `Active profile: ⚡ ${profile.name}${
620
+ profile.description ? ` — ${profile.description}` : ""
621
+ }`,
622
+ ];
623
+ for (const roleName of PROFILE_ROLES) {
624
+ const m = profile.roles[roleName];
625
+ if (m)
626
+ lines.push(
627
+ ` ${roleIcon(roleName)} ${roleName} → ${formatModelRef(m)} (${m.thinking}) (profile override)`,
628
+ );
629
+ }
630
+ lines.push(
631
+ ` /profile edit ${profile.name} to change the profile • /profile use none to deactivate`,
632
+ );
633
+ return lines.join("\n");
634
+ }
635
+
636
+ /** Multi-line notification describing what an activation resolves to. */
637
+ function profileActivationSummary(name: string): string {
638
+ const profile = getProfile(profilesConfig, name);
639
+ if (!profile) return `Profile "${name}" activated.`;
640
+ const lines = [`Profile ⚡ ${profile.name} activated:`];
641
+ for (const roleName of PROFILE_ROLES) {
642
+ const m = profile.roles[roleName];
643
+ if (m)
644
+ lines.push(
645
+ ` ${roleIcon(roleName)} ${roleName} → ${formatModelRef(m)} (${m.thinking})`,
646
+ );
647
+ }
648
+ const missing = missingProfileRoles(profile);
649
+ if (missing.length) {
650
+ lines.push(
651
+ ` ${missing.join(", ")}: base role models (not overridden by this profile)`,
652
+ );
653
+ }
654
+ if (workflowMode) {
655
+ lines.push(` Applied to ${workflowMode} mode now; explore/review pick it up on next use.`);
656
+ } else {
657
+ lines.push(
658
+ " Applies on the next /plan, /build, explore subagent, or review round.",
659
+ );
660
+ }
661
+ return lines.join("\n");
662
+ }
663
+
664
+ /**
665
+ * Activate/deactivate the session's profile. Session-scoped by design — the
666
+ * state rides the `workflow` session entry (persistState), never a global
667
+ * file, so two concurrent sessions can run different profiles. When the
668
+ * session is in plan/build mode the role model is re-applied immediately so
669
+ * the profile's planner/builder take effect right away; explorer/reviewer
670
+ * resolution picks the profile up on next subagent/review round.
671
+ */
672
+ async function setActiveProfile(
673
+ ctx: ExtensionContext,
674
+ name: string | null,
675
+ opts?: { explicit?: boolean; quiet?: boolean },
676
+ ): Promise<void> {
677
+ ensureProfilesConfig((ctx as any)?.cwd);
678
+ const trimmed = name ? String(name).trim() : null;
679
+ if (trimmed) {
680
+ const profile = getProfile(profilesConfig, trimmed);
681
+ if (!profile) {
682
+ ctx.ui.notify(
683
+ `Unknown profile "${trimmed}". Available: ${
684
+ profilesConfig.profiles.map((p) => p.name).join(", ") ||
685
+ "(none — /profile create <name>)"
686
+ }`,
687
+ "warning",
688
+ );
689
+ return;
690
+ }
691
+ activeProfileName = profile.name;
692
+ } else {
693
+ activeProfileName = null;
694
+ }
695
+ profileExplicit = opts?.explicit ?? true;
696
+ invalidateEffectiveRoles();
697
+ try {
698
+ persistState();
699
+ } catch (_e) {
700
+ void _e;
701
+ }
702
+ updateStatus(ctx as any);
703
+ if (workflowMode) {
704
+ try {
705
+ await applyModeModel(ctx as any, { quiet: opts?.quiet });
706
+ } catch (_e) {
707
+ void _e;
708
+ }
709
+ }
710
+ if (!opts?.quiet) {
711
+ ctx.ui.notify(
712
+ activeProfileName
713
+ ? profileActivationSummary(activeProfileName)
714
+ : "Profile deactivated — default roles flow active.",
715
+ "info",
716
+ );
717
+ }
718
+ }
719
+
476
720
  function activeRoleLabel(): string {
477
721
  if (roleConfig.activeRole)
478
722
  return `${roleIcon(roleConfig.activeRole)} ${roleConfig.activeRole}`;
@@ -623,8 +867,11 @@ export default function workflowExtension(pi: ExtensionAPI) {
623
867
  }
624
868
 
625
869
  function updateStatus(ctx: ExtensionContext) {
870
+ // Profile prefix (⚡ name) when one is active, then the role label.
871
+ const profileLabel = activeProfileLabel();
626
872
  const roleLabel = activeRoleLabel();
627
- const suffix = roleLabel ? ` | ${roleLabel}` : "";
873
+ const statusParts = [profileLabel, roleLabel].filter(Boolean);
874
+ const suffix = statusParts.length ? ` | ${statusParts.join(" | ")}` : "";
628
875
  // Commit-policy surfacing (workflow.commitPolicy): always visible so the
629
876
  // user can see at a glance what the agent may do to git history. Read
630
877
  // live so /commit-policy changes show up without a reload.
@@ -719,7 +966,8 @@ export default function workflowExtension(pi: ExtensionAPI) {
719
966
  /** Reviewer model label for the mode band (best-effort). */
720
967
  function reviewModelLabel(): string {
721
968
  ensureRoleConfig(reviewCwd());
722
- const r = getRole(roleConfig, REVIEW_ROLE_NAME);
969
+ ensureProfilesConfig(reviewCwd());
970
+ const r = getEffectiveRole(REVIEW_ROLE_NAME);
723
971
  return r?.model ? formatModelRef(r.model) : "unconfigured";
724
972
  }
725
973
 
@@ -887,9 +1135,11 @@ export default function workflowExtension(pi: ExtensionAPI) {
887
1135
  ): Promise<string> {
888
1136
  return (async () => {
889
1137
  const cwd = reviewCwd();
1138
+ ensureRoleConfig(cwd);
1139
+ ensureProfilesConfig(cwd);
890
1140
  const agents = discoverAgents(cwd);
891
1141
  const base = agents.find((a) => a.name === "scout") ?? agents[0];
892
- const explorerRole = getRole(roleConfig, "explorer");
1142
+ const explorerRole = getEffectiveRole("explorer");
893
1143
  const m = explorerRole?.model;
894
1144
  const agent: AgentConfig = {
895
1145
  name: base?.name ?? "review-scout",
@@ -927,11 +1177,12 @@ export default function workflowExtension(pi: ExtensionAPI) {
927
1177
  config: () => reviewConfigNow()?.config,
928
1178
  reviewerModel: () => {
929
1179
  ensureRoleConfig(cwd);
930
- const reviewer = getRole(roleConfig, REVIEW_ROLE_NAME);
1180
+ ensureProfilesConfig(cwd);
1181
+ const reviewer = getEffectiveRole(REVIEW_ROLE_NAME);
931
1182
  if (reviewer?.model) return reviewer.model;
932
1183
  // Graceful degradation: fall back to the planner model rather than
933
1184
  // silently running no review at all.
934
- const planner = getRole(roleConfig, "planner");
1185
+ const planner = getEffectiveRole("planner");
935
1186
  return planner?.model;
936
1187
  },
937
1188
  findModel: (provider: string, id: string) =>
@@ -1131,7 +1382,8 @@ export default function workflowExtension(pi: ExtensionAPI) {
1131
1382
  if (!sub || sub === "status") {
1132
1383
  const resolved = readReviewModeConfig(cwd, agentDir);
1133
1384
  const c = resolved.config;
1134
- const model = getRole(roleConfig, REVIEW_ROLE_NAME)?.model;
1385
+ ensureProfilesConfig(cwd);
1386
+ const model = getEffectiveRole(REVIEW_ROLE_NAME)?.model;
1135
1387
  ctx.ui.notify(
1136
1388
  formatReviewStatusLines({
1137
1389
  enabled: c.enabled,
@@ -1507,13 +1759,18 @@ export default function workflowExtension(pi: ExtensionAPI) {
1507
1759
  if (!workflowMode) return false;
1508
1760
  try {
1509
1761
  ensureRoleConfig((ctx as any)?.cwd ?? ctx.cwd);
1762
+ ensureProfilesConfig((ctx as any)?.cwd ?? ctx.cwd);
1510
1763
  } catch (_e) {
1511
1764
  void _e;
1512
1765
  }
1513
- const resolved = resolveModeModel(roleConfig, workflowMode);
1766
+ // Resolution reads the EFFECTIVE roles (base roles + active profile
1767
+ // overlay); with no profile active this is behaviourally identical to
1768
+ // the base config.
1769
+ const effectiveCfg: RoleConfig = { ...roleConfig, roles: effectiveRoles() };
1770
+ const resolved = resolveModeModel(effectiveCfg, workflowMode);
1514
1771
  if (!resolved) {
1515
- const targetName = roleConfig.activeRole ?? MODE_ROLE_MAP[workflowMode];
1516
- const role = targetName ? getRole(roleConfig, targetName) : undefined;
1772
+ const targetName = effectiveCfg.activeRole ?? MODE_ROLE_MAP[workflowMode];
1773
+ const role = targetName ? getRole(effectiveCfg, targetName) : undefined;
1517
1774
  if (targetName && !role) {
1518
1775
  ctx.ui.notify(
1519
1776
  `Workflow ${workflowMode}: role "${targetName}" not found — staying on current model.`,
@@ -1699,6 +1956,10 @@ export default function workflowExtension(pi: ExtensionAPI) {
1699
1956
  awaitingDecisionAt: awaitingDecision ? lastHandoffAt : undefined,
1700
1957
  role: {
1701
1958
  activeRole: roleConfig.activeRole,
1959
+ // Session-scoped profile activation (see setActiveProfile): explicit
1960
+ // picks restore exactly; non-explicit entries re-derive the default.
1961
+ activeProfile: profileExplicit ? activeProfileName : null,
1962
+ profileExplicit,
1702
1963
  },
1703
1964
  defaultModel,
1704
1965
  lastModeSwitch,
@@ -2782,19 +3043,19 @@ export default function workflowExtension(pi: ExtensionAPI) {
2782
3043
  await openRolePicker(ctx as any);
2783
3044
  return;
2784
3045
  }
2785
- ctx.ui.notify(formatRolesForDisplay(roleConfig), "info");
3046
+ ctx.ui.notify(formatRolesDisplayWithProfile(), "info");
2786
3047
  return;
2787
3048
  }
2788
3049
 
2789
3050
  if (sub === "list" || sub === "show") {
2790
- ctx.ui.notify(formatRolesForDisplay(roleConfig), "info");
3051
+ ctx.ui.notify(formatRolesDisplayWithProfile(), "info");
2791
3052
  return;
2792
3053
  }
2793
3054
 
2794
3055
  if (sub === "config") {
2795
3056
  if (!(ctx as any)?.hasUI) {
2796
3057
  ctx.ui.notify(
2797
- formatRolesForDisplay(roleConfig) +
3058
+ formatRolesDisplayWithProfile() +
2798
3059
  "\n\nPicker needs UI — use /role set instead.",
2799
3060
  "info",
2800
3061
  );
@@ -2974,118 +3235,1491 @@ export default function workflowExtension(pi: ExtensionAPI) {
2974
3235
  },
2975
3236
  });
2976
3237
 
2977
- // ── Role config screen (tabbed picker: one block per role) ─────────
3238
+ // ── Model profiles command + management UI ───────────────────────
2978
3239
  //
2979
- // One overlay with a tab per role (built-ins first, then customs) plus
2980
- // a Submit tab. Each role tab has two phases: pick model (from the
2981
- // connected providers — scoped models first, Ctrl+O for all) then pick
2982
- // thinking level. Selections stage locally; Submit saves to roles.json.
2983
-
2984
- interface StagedRoleModel {
2985
- provider: string;
2986
- id: string;
2987
- thinking: ThinkingLevel;
2988
- }
2989
-
2990
- interface RoleCatalogEntry {
2991
- provider: string;
2992
- id: string;
2993
- inScope: boolean;
2994
- }
2995
-
2996
- function collectRoleCatalog(ctx: ExtensionContext): {
2997
- scoped: RoleCatalogEntry[];
2998
- all: RoleCatalogEntry[];
2999
- truncated: boolean;
3000
- } {
3001
- const scoped: RoleCatalogEntry[] = [];
3002
- try {
3003
- const raw = ((ctx as any)?.scopedModels ?? []) as any[];
3004
- for (const s of raw) {
3005
- const m = s?.model ?? s;
3006
- if (m && typeof m.provider === "string" && typeof m.id === "string")
3007
- scoped.push({ provider: m.provider, id: m.id, inScope: true });
3008
- }
3009
- } catch (_e) {
3010
- void _e;
3011
- }
3012
- let available: any[] = [];
3240
+ // A profile is a named combination of role model settings (planner /
3241
+ // explorer / builder / reviewer). Profiles are DEFINED in profiles.json;
3242
+ // ACTIVATION is session-scoped (setActiveProfile) so two concurrent
3243
+ // sessions can run different profiles. /role keeps editing the base roles;
3244
+ // an active profile only overlays models on top (see effectiveRoles()).
3245
+
3246
+ const PROFILE_USAGE =
3247
+ "Usage: /profile (picker) | /profile list | /profile show <name> | " +
3248
+ "/profile use <name|none> | /profile create <name> | /profile edit <name> | " +
3249
+ "/profile rename <old> <new> | /profile delete <name> | " +
3250
+ "/profile default [name|none] [global|project]";
3251
+
3252
+ /** Effective workflow.defaultProfile for display (never throws). */
3253
+ function currentDefaultProfileName(cwd?: string): string | undefined {
3013
3254
  try {
3014
- const reg = (ctx as any)?.modelRegistry;
3015
- if (reg && typeof reg.getAvailable === "function")
3016
- available = [...reg.getAvailable()];
3017
- else if (reg && typeof reg.getAll === "function")
3018
- available = [...reg.getAll()];
3255
+ return readDefaultProfileSetting(cwd ?? process.cwd(), getAgentDir())
3256
+ .value;
3019
3257
  } catch (_e) {
3020
3258
  void _e;
3259
+ return undefined;
3021
3260
  }
3022
- const seen = new Set(scoped.map((s) => `${s.provider}/${s.id}`));
3023
- const rest: RoleCatalogEntry[] = [];
3024
- for (const m of available) {
3025
- if (!m || typeof m.provider !== "string" || typeof m.id !== "string")
3026
- continue;
3027
- const key = `${m.provider}/${m.id}`;
3028
- if (seen.has(key)) continue;
3029
- seen.add(key);
3030
- rest.push({ provider: m.provider, id: m.id, inScope: false });
3261
+ }
3262
+
3263
+ /** Seed a new profile from the CURRENT effective roles (base + active profile). */
3264
+ function buildProfileSeed(name: string, description = ""): Profile {
3265
+ const seed: Profile = { name, description, roles: {} };
3266
+ for (const roleName of PROFILE_ROLES) {
3267
+ const m = getEffectiveRole(roleName)?.model;
3268
+ if (m) seed.roles[roleName] = { ...m };
3031
3269
  }
3032
- const CAP = 200;
3033
- const all = [...scoped, ...rest];
3034
- return {
3035
- scoped,
3036
- all: all.slice(0, CAP),
3037
- truncated: all.length > CAP,
3038
- };
3270
+ return seed;
3039
3271
  }
3040
3272
 
3041
- function orderedRoleTabs(): Role[] {
3042
- // Built-in roles first in their declared order, then custom roles.
3043
- // Driven by BUILT_IN_ROLES so adding a role never needs a second edit here.
3044
- const rank = (name: string) => {
3045
- const i = (BUILT_IN_ROLES as readonly string[]).indexOf(
3046
- name.toLowerCase(),
3047
- );
3048
- return i === -1 ? BUILT_IN_ROLES.length : i;
3049
- };
3050
- return [...roleConfig.roles].sort((a, b) => rank(a.name) - rank(b.name));
3273
+ const listThemeOf = (theme: any) => ({
3274
+ selectedPrefix: (t: string) => theme.fg("accent", t),
3275
+ selectedText: (t: string) => theme.fg("accent", t),
3276
+ description: (t: string) => theme.fg("muted", t),
3277
+ scrollInfo: (t: string) => theme.fg("dim", t),
3278
+ noMatch: (t: string) => theme.fg("warning", t),
3279
+ });
3280
+
3281
+ /** Small confirm overlay (used by /profile delete). True only on explicit Delete. */
3282
+ async function confirmProfileDelete(
3283
+ ctx: ExtensionContext,
3284
+ profile: Profile,
3285
+ ): Promise<boolean> {
3286
+ const result = await ctx.ui.custom<boolean | null>(
3287
+ (tui, theme, _kb, done) => {
3288
+ const list = new SelectList(
3289
+ [
3290
+ {
3291
+ value: "delete",
3292
+ label: `Delete ⚡ ${profile.name}`,
3293
+ description: "remove from profiles.json (cannot be undone)",
3294
+ },
3295
+ {
3296
+ value: "cancel",
3297
+ label: "Cancel",
3298
+ description: "keep the profile",
3299
+ },
3300
+ ],
3301
+ 2,
3302
+ listThemeOf(theme),
3303
+ );
3304
+ let cached: string[] | undefined;
3305
+ return {
3306
+ render: (width: number) => {
3307
+ if (cached) return cached;
3308
+ const W = Math.max(1, width);
3309
+ const lines: string[] = [];
3310
+ lines.push(theme.fg("accent", "─".repeat(W)));
3311
+ lines.push(
3312
+ ` ${theme.fg("warning", `Delete profile "${profile.name}"?`)}`,
3313
+ );
3314
+ lines.push("");
3315
+ for (const l of list.render(Math.max(1, W - 2))) lines.push(` ${l}`);
3316
+ lines.push("");
3317
+ lines.push(
3318
+ ` ${theme.fg("dim", "↑↓ navigate • Enter select • Esc cancel")}`,
3319
+ );
3320
+ lines.push(theme.fg("accent", "─".repeat(W)));
3321
+ cached = lines;
3322
+ return lines;
3323
+ },
3324
+ invalidate: () => {
3325
+ cached = undefined;
3326
+ },
3327
+ handleInput: (data: string) => {
3328
+ if (matchesKey(data, Key.escape)) {
3329
+ done(null);
3330
+ return;
3331
+ }
3332
+ if (matchesKey(data, Key.enter)) {
3333
+ done(list.getSelectedItem()?.value === "delete");
3334
+ return;
3335
+ }
3336
+ list.handleInput(data);
3337
+ cached = undefined;
3338
+ tui.requestRender();
3339
+ },
3340
+ };
3341
+ },
3342
+ );
3343
+ return result === true;
3051
3344
  }
3052
3345
 
3053
- async function openRolePicker(ctx: ExtensionContext): Promise<void> {
3054
- const tabs = orderedRoleTabs();
3055
- if (tabs.length === 0) {
3056
- ctx.ui.notify("No roles configured — /role reset to reseed.", "warning");
3057
- return;
3058
- }
3059
- const catalog = collectRoleCatalog(ctx);
3060
- if (catalog.all.length === 0) {
3061
- ctx.ui.notify(
3062
- "No models available from connected providers — connect a provider first, or use /role set <role> <provider/model> [thinking].",
3063
- "warning",
3064
- );
3065
- return;
3066
- }
3067
- // Stage current values so the picker opens on the active selection.
3068
- const staged = new Map<string, StagedRoleModel>();
3069
- for (const r of tabs) staged.set(r.name, { ...r.model });
3346
+ /**
3347
+ * Free-text profile name entry (Editor overlay, same input pattern as the
3348
+ * questionnaire). Resolves the validated name, or null on cancel/invalid-Esc.
3349
+ */
3350
+ async function inputProfileName(
3351
+ ctx: ExtensionContext,
3352
+ opts: {
3353
+ title: string;
3354
+ initial?: string;
3355
+ /** Names that would collide (case-insensitive), minus any being renamed. */
3356
+ existing?: readonly string[];
3357
+ },
3358
+ ): Promise<string | null> {
3359
+ return ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
3360
+ const editorTheme: EditorTheme = {
3361
+ borderColor: (s) => theme.fg("accent", s),
3362
+ selectList: {
3363
+ selectedPrefix: (t: string) => theme.fg("accent", t),
3364
+ selectedText: (t: string) => theme.fg("accent", t),
3365
+ description: (t: string) => theme.fg("muted", t),
3366
+ scrollInfo: (t: string) => theme.fg("dim", t),
3367
+ noMatch: (t: string) => theme.fg("warning", t),
3368
+ },
3369
+ };
3370
+ const editor = new Editor(tui, editorTheme);
3371
+ if (opts.initial) editor.setText(opts.initial);
3372
+ let error = "";
3373
+ let cached: string[] | undefined;
3374
+ editor.onSubmit = (value: string) => {
3375
+ const check = validateProfileName(String(value ?? ""), opts.existing);
3376
+ if (!check.ok) {
3377
+ error = check.error;
3378
+ cached = undefined;
3379
+ tui.requestRender();
3380
+ return;
3381
+ }
3382
+ done(check.value);
3383
+ };
3384
+ return {
3385
+ render: (width: number) => {
3386
+ if (cached) return cached;
3387
+ const W = Math.max(1, width);
3388
+ const lines: string[] = [];
3389
+ lines.push(theme.fg("accent", "─".repeat(W)));
3390
+ lines.push(
3391
+ ...wrapTextWithAnsi(` ${theme.fg("text", opts.title)}`, W),
3392
+ );
3393
+ if (error)
3394
+ lines.push(
3395
+ ...wrapTextWithAnsi(` ${theme.fg("warning", error)}`, W),
3396
+ );
3397
+ lines.push("");
3398
+ for (const l of editor.render(Math.max(1, W))) lines.push(l);
3399
+ lines.push("");
3400
+ lines.push(` ${theme.fg("dim", "Enter confirm • Esc cancel")}`);
3401
+ lines.push(theme.fg("accent", "─".repeat(W)));
3402
+ cached = lines;
3403
+ return lines;
3404
+ },
3405
+ invalidate: () => {
3406
+ cached = undefined;
3407
+ },
3408
+ handleInput: (data: string) => {
3409
+ if (matchesKey(data, Key.escape)) {
3410
+ done(null);
3411
+ return;
3412
+ }
3413
+ editor.handleInput(data);
3414
+ cached = undefined;
3415
+ tui.requestRender();
3416
+ },
3417
+ };
3418
+ });
3419
+ }
3070
3420
 
3071
- overlayActive = true;
3072
- let saved: Map<string, StagedRoleModel> | null = null;
3073
- try {
3074
- saved = await ctx.ui.custom<Map<string, StagedRoleModel> | null>(
3421
+ /**
3422
+ * Profile management picker: activate / none / new / per-profile actions
3423
+ * (edit, rename, delete) and the default-profile entry. Loops so actions
3424
+ * return to the list; every action handles its own persistence.
3425
+ */
3426
+ async function openProfilePicker(ctx: ExtensionContext): Promise<void> {
3427
+ ensureProfilesConfig((ctx as any)?.cwd);
3428
+ ensureRoleConfig((ctx as any)?.cwd);
3429
+ while (true) {
3430
+ const profiles = profilesConfig.profiles;
3431
+ const def = currentDefaultProfileName((ctx as any)?.cwd);
3432
+ const entries: SelectItem[] = [];
3433
+ entries.push({
3434
+ value: "none",
3435
+ label: activeProfileName
3436
+ ? "— none (default roles) —"
3437
+ : "● — none (default roles) —",
3438
+ description: "deactivate any profile; the roles.json flow applies",
3439
+ });
3440
+ for (const p of profiles) {
3441
+ const isActive =
3442
+ activeProfileName?.toLowerCase() === p.name.toLowerCase();
3443
+ const isDefault = def?.toLowerCase() === p.name.toLowerCase();
3444
+ const markers = [
3445
+ isActive ? "● active" : "",
3446
+ isDefault ? "★ default" : "",
3447
+ ]
3448
+ .filter(Boolean)
3449
+ .join(", ");
3450
+ const modelSummary = PROFILE_ROLES.map((r) => {
3451
+ const m = p.roles[r];
3452
+ return m ? `${roleIcon(r)} ${formatModelRef(m)}` : null;
3453
+ })
3454
+ .filter(Boolean)
3455
+ .join(" · ");
3456
+ entries.push({
3457
+ value: p.name,
3458
+ label: `⚡ ${p.name}${markers ? ` (${markers})` : ""}`,
3459
+ description: p.description || modelSummary || undefined,
3460
+ });
3461
+ }
3462
+ entries.push({
3463
+ value: "__new",
3464
+ label: "+ New profile…",
3465
+ description: "name it, then pick a model per role (like /role)",
3466
+ });
3467
+ entries.push({
3468
+ value: "__default",
3469
+ label: "Default for new sessions…",
3470
+ description: `workflow.defaultProfile — currently: ${def ?? "(none)"}`,
3471
+ });
3472
+
3473
+ const selected = await ctx.ui.custom<string | null>(
3075
3474
  (tui, theme, _kb, done) => {
3076
- let currentTab = 0; // tabs.length = Submit
3077
- let phase: "model" | "thinking" = "model";
3078
- let showAll = catalog.scoped.length === 0;
3475
+ const list = new SelectList(entries, 10, listThemeOf(theme));
3079
3476
  let cached: string[] | undefined;
3080
- // One SelectList per role tab for models + one shared for thinking,
3081
- // so filter text + cursor survive tab switches.
3082
- const listTheme = {
3083
- selectedPrefix: (t: string) => theme.fg("accent", t),
3084
- selectedText: (t: string) => theme.fg("accent", t),
3085
- description: (t: string) => theme.fg("muted", t),
3086
- scrollInfo: (t: string) => theme.fg("dim", t),
3087
- noMatch: (t: string) => theme.fg("warning", t),
3088
- };
3477
+ return {
3478
+ render: (width: number) => {
3479
+ if (cached) return cached;
3480
+ const W = Math.max(1, width);
3481
+ const lines: string[] = [];
3482
+ lines.push(theme.fg("accent", "─".repeat(W)));
3483
+ lines.push(
3484
+ ` ${theme.fg("text", "Model Profiles")} ${theme.fg(
3485
+ "muted",
3486
+ `— active: ${activeProfileName ?? "none (default roles)"}`,
3487
+ )}`,
3488
+ );
3489
+ lines.push("");
3490
+ for (const l of list.render(Math.max(1, W - 2)))
3491
+ lines.push(` ${l}`);
3492
+ lines.push("");
3493
+ lines.push(
3494
+ ` ${theme.fg("dim", "Enter: select/action • type to filter • Esc close")}`,
3495
+ );
3496
+ lines.push(theme.fg("accent", "─".repeat(W)));
3497
+ cached = lines;
3498
+ return lines;
3499
+ },
3500
+ invalidate: () => {
3501
+ cached = undefined;
3502
+ },
3503
+ handleInput: (data: string) => {
3504
+ if (matchesKey(data, Key.escape)) {
3505
+ done(null);
3506
+ return;
3507
+ }
3508
+ if (matchesKey(data, Key.enter)) {
3509
+ done(list.getSelectedItem()?.value ?? null);
3510
+ return;
3511
+ }
3512
+ list.handleInput(data);
3513
+ cached = undefined;
3514
+ tui.requestRender();
3515
+ },
3516
+ };
3517
+ },
3518
+ );
3519
+ if (!selected) return; // Esc / closed
3520
+ if (selected === "__new") {
3521
+ const name = await inputProfileName(ctx, {
3522
+ title: "New profile — name (dashes, no spaces):",
3523
+ existing: profilesConfig.profiles.map((p) => p.name),
3524
+ });
3525
+ if (!name) continue;
3526
+ await openProfileEditor(ctx, { createName: name });
3527
+ continue;
3528
+ }
3529
+ if (selected === "__default") {
3530
+ await pickDefaultProfile(ctx);
3531
+ continue;
3532
+ }
3533
+ if (selected === "none") {
3534
+ if (activeProfileName) {
3535
+ await setActiveProfile(ctx as any, null, { explicit: true });
3536
+ } else {
3537
+ ctx.ui.notify("No profile active — default roles flow.", "info");
3538
+ }
3539
+ continue;
3540
+ }
3541
+ // A concrete profile → action menu
3542
+ const profile = getProfile(profilesConfig, selected);
3543
+ if (!profile) continue;
3544
+ const action = await ctx.ui.custom<string | null>(
3545
+ (tui, theme, _kb, done) => {
3546
+ const isActive =
3547
+ activeProfileName?.toLowerCase() === profile.name.toLowerCase();
3548
+ const list = new SelectList(
3549
+ [
3550
+ isActive
3551
+ ? {
3552
+ value: "deactivate",
3553
+ label: "Deactivate",
3554
+ description: "back to default roles (this session)",
3555
+ }
3556
+ : {
3557
+ value: "activate",
3558
+ label: "Activate now",
3559
+ description:
3560
+ "session-scoped; plan/build re-apply immediately",
3561
+ },
3562
+ {
3563
+ value: "edit",
3564
+ label: "Edit models",
3565
+ description: "tabbed editor — model + thinking per role",
3566
+ },
3567
+ {
3568
+ value: "rename",
3569
+ label: "Rename",
3570
+ description: "change the profile name",
3571
+ },
3572
+ {
3573
+ value: "delete",
3574
+ label: "Delete",
3575
+ description: "remove from profiles.json",
3576
+ },
3577
+ { value: "back", label: "Back", description: "return to the profile list" },
3578
+ ],
3579
+ 5,
3580
+ listThemeOf(theme),
3581
+ );
3582
+ let cached: string[] | undefined;
3583
+ return {
3584
+ render: (width: number) => {
3585
+ if (cached) return cached;
3586
+ const W = Math.max(1, width);
3587
+ const lines: string[] = [];
3588
+ lines.push(theme.fg("accent", "─".repeat(W)));
3589
+ lines.push(
3590
+ ` ${theme.fg("text", `⚡ ${profile.name}`)}${
3591
+ profile.description
3592
+ ? theme.fg("muted", ` — ${profile.description}`)
3593
+ : ""
3594
+ }`,
3595
+ );
3596
+ lines.push("");
3597
+ for (const l of list.render(Math.max(1, W - 2)))
3598
+ lines.push(` ${l}`);
3599
+ lines.push("");
3600
+ lines.push(` ${theme.fg("dim", "Enter select • Esc back")}`);
3601
+ lines.push(theme.fg("accent", "─".repeat(W)));
3602
+ cached = lines;
3603
+ return lines;
3604
+ },
3605
+ invalidate: () => {
3606
+ cached = undefined;
3607
+ },
3608
+ handleInput: (data: string) => {
3609
+ if (matchesKey(data, Key.escape)) {
3610
+ done(null);
3611
+ return;
3612
+ }
3613
+ if (matchesKey(data, Key.enter)) {
3614
+ done(list.getSelectedItem()?.value ?? null);
3615
+ return;
3616
+ }
3617
+ list.handleInput(data);
3618
+ cached = undefined;
3619
+ tui.requestRender();
3620
+ },
3621
+ };
3622
+ },
3623
+ );
3624
+ if (!action || action === "back") continue;
3625
+ if (action === "activate") {
3626
+ await setActiveProfile(ctx as any, profile.name, { explicit: true });
3627
+ continue;
3628
+ }
3629
+ if (action === "deactivate") {
3630
+ await setActiveProfile(ctx as any, null, { explicit: true });
3631
+ continue;
3632
+ }
3633
+ if (action === "edit") {
3634
+ await openProfileEditor(ctx, { name: profile.name });
3635
+ continue;
3636
+ }
3637
+ if (action === "rename") {
3638
+ const newName = await inputProfileName(ctx, {
3639
+ title: `Rename profile "${profile.name}" to:`,
3640
+ initial: profile.name,
3641
+ existing: profilesConfig.profiles
3642
+ .filter((p) => p !== profile)
3643
+ .map((p) => p.name),
3644
+ });
3645
+ if (newName && newName !== profile.name) {
3646
+ const wasActive =
3647
+ activeProfileName?.toLowerCase() === profile.name.toLowerCase();
3648
+ profile.name = newName;
3649
+ if (wasActive) activeProfileName = profile.name;
3650
+ invalidateEffectiveRoles();
3651
+ try {
3652
+ saveProfiles(profilesConfig, "user");
3653
+ ctx.ui.notify(`Profile renamed → ⚡ ${profile.name}.`, "info");
3654
+ } catch (e: any) {
3655
+ ctx.ui.notify(
3656
+ `Renamed in memory but file save failed: ${String(e?.message || e)}`,
3657
+ "warning",
3658
+ );
3659
+ }
3660
+ updateStatus(ctx as any);
3661
+ }
3662
+ continue;
3663
+ }
3664
+ if (action === "delete") {
3665
+ const confirmed = await confirmProfileDelete(ctx, profile);
3666
+ if (!confirmed) continue;
3667
+ const wasActive =
3668
+ activeProfileName?.toLowerCase() === profile.name.toLowerCase();
3669
+ profilesConfig.profiles = profilesConfig.profiles.filter(
3670
+ (p) => p !== profile,
3671
+ );
3672
+ if (wasActive) {
3673
+ activeProfileName = null;
3674
+ invalidateEffectiveRoles();
3675
+ }
3676
+ try {
3677
+ saveProfiles(profilesConfig, "user");
3678
+ ctx.ui.notify(
3679
+ `Profile ⚡ ${profile.name} deleted.${
3680
+ wasActive
3681
+ ? " It was active — default roles flow restored."
3682
+ : ""
3683
+ }`,
3684
+ "info",
3685
+ );
3686
+ } catch (e: any) {
3687
+ ctx.ui.notify(
3688
+ `Deleted in memory but file save failed: ${String(e?.message || e)}`,
3689
+ "warning",
3690
+ );
3691
+ }
3692
+ if (wasActive && workflowMode) {
3693
+ try {
3694
+ await applyModeModel(ctx as any);
3695
+ } catch (_e) {
3696
+ void _e;
3697
+ }
3698
+ }
3699
+ updateStatus(ctx as any);
3700
+ continue;
3701
+ }
3702
+ }
3703
+ }
3704
+
3705
+ /** Picker for the default profile (workflow.defaultProfile) + its scope. */
3706
+ async function pickDefaultProfile(ctx: ExtensionContext): Promise<void> {
3707
+ const cwd = (ctx as any)?.cwd as string | undefined;
3708
+ const current = currentDefaultProfileName(cwd);
3709
+ const entries: SelectItem[] = [
3710
+ {
3711
+ value: "__none",
3712
+ label: "— none —",
3713
+ description: "new sessions start without a profile",
3714
+ },
3715
+ ...profilesConfig.profiles.map((p) => ({
3716
+ value: p.name,
3717
+ label: `⚡ ${p.name}`,
3718
+ description: p.description || undefined,
3719
+ })),
3720
+ ];
3721
+ const name = await ctx.ui.custom<string | null>(
3722
+ (tui, theme, _kb, done) => {
3723
+ const list = new SelectList(entries, 8, listThemeOf(theme));
3724
+ let cached: string[] | undefined;
3725
+ return {
3726
+ render: (width: number) => {
3727
+ if (cached) return cached;
3728
+ const W = Math.max(1, width);
3729
+ const lines: string[] = [];
3730
+ lines.push(theme.fg("accent", "─".repeat(W)));
3731
+ lines.push(
3732
+ ` ${theme.fg("text", "Default profile for new sessions")} ${theme.fg(
3733
+ "muted",
3734
+ `— currently: ${current ?? "(none)"}`,
3735
+ )}`,
3736
+ );
3737
+ lines.push("");
3738
+ for (const l of list.render(Math.max(1, W - 2)))
3739
+ lines.push(` ${l}`);
3740
+ lines.push("");
3741
+ lines.push(` ${theme.fg("dim", "Enter select • Esc cancel")}`);
3742
+ lines.push(theme.fg("accent", "─".repeat(W)));
3743
+ cached = lines;
3744
+ return lines;
3745
+ },
3746
+ invalidate: () => {
3747
+ cached = undefined;
3748
+ },
3749
+ handleInput: (data: string) => {
3750
+ if (matchesKey(data, Key.escape)) {
3751
+ done(null);
3752
+ return;
3753
+ }
3754
+ if (matchesKey(data, Key.enter)) {
3755
+ done(list.getSelectedItem()?.value ?? null);
3756
+ return;
3757
+ }
3758
+ list.handleInput(data);
3759
+ cached = undefined;
3760
+ tui.requestRender();
3761
+ },
3762
+ };
3763
+ },
3764
+ );
3765
+ if (name === null) return;
3766
+ // Keep the scope where the setting currently lives (else global).
3767
+ const resolved = readDefaultProfileSetting(
3768
+ cwd ?? process.cwd(),
3769
+ getAgentDir(),
3770
+ );
3771
+ const scope: "global" | "project" =
3772
+ resolved.sources.effective === "project" ? "project" : "global";
3773
+ const value = name === "__none" ? null : name;
3774
+ const res = writeDefaultProfileSetting(
3775
+ scope,
3776
+ cwd ?? process.cwd(),
3777
+ getAgentDir(),
3778
+ value,
3779
+ );
3780
+ if (!res.ok) {
3781
+ ctx.ui.notify(
3782
+ `Could not write default profile: ${res.error ?? "unknown error"}`,
3783
+ "error",
3784
+ );
3785
+ return;
3786
+ }
3787
+ ctx.ui.notify(
3788
+ value
3789
+ ? `Default profile → ⚡ ${value} (${scope} settings)\nNew sessions without an explicit /profile use start on it.`
3790
+ : `Default profile cleared (${scope} settings).`,
3791
+ "info",
3792
+ );
3793
+ updateStatus(ctx as any);
3794
+ }
3795
+
3796
+ interface StagedProfileModel {
3797
+ provider: string;
3798
+ id: string;
3799
+ thinking: ThinkingLevel;
3800
+ }
3801
+
3802
+ /**
3803
+ * Tabbed profile editor — one tab per built-in role (planner / explorer /
3804
+ * builder / reviewer, mirroring /role) plus two submit tabs: Save &
3805
+ * activate and Save only. Selections stage locally; saving writes
3806
+ * profiles.json (user scope). `createName` creates a new profile seeded
3807
+ * from the current effective roles; `name` edits an existing one.
3808
+ */
3809
+ async function openProfileEditor(
3810
+ ctx: ExtensionContext,
3811
+ opts: { createName?: string; name?: string },
3812
+ ): Promise<void> {
3813
+ ensureProfilesConfig((ctx as any)?.cwd);
3814
+ ensureRoleConfig((ctx as any)?.cwd);
3815
+ const catalog = collectRoleCatalog(ctx);
3816
+ if (catalog.all.length === 0) {
3817
+ ctx.ui.notify(
3818
+ "No models available from connected providers — connect a provider first.",
3819
+ "warning",
3820
+ );
3821
+ return;
3822
+ }
3823
+ const existing = opts.name
3824
+ ? getProfile(profilesConfig, opts.name)
3825
+ : undefined;
3826
+ if (opts.name && !existing) {
3827
+ ctx.ui.notify(`Profile "${opts.name}" no longer exists.`, "warning");
3828
+ return;
3829
+ }
3830
+ const isNew = !existing;
3831
+ const profileName = existing
3832
+ ? existing.name
3833
+ : (opts.createName ?? "").trim();
3834
+ if (!profileName) {
3835
+ ctx.ui.notify("Profile name required.", "warning");
3836
+ return;
3837
+ }
3838
+ const roleMeta = new Map<string, { name: string; description: string }>();
3839
+ for (const roleName of PROFILE_ROLES) {
3840
+ const base = getRole(roleConfig, roleName);
3841
+ roleMeta.set(roleName, {
3842
+ name: roleName,
3843
+ description: base?.description ?? `Role ${roleName}`,
3844
+ });
3845
+ }
3846
+ // Keep the narrow tuple type so profile.roles[roleName] typechecks.
3847
+ const tabIds = [...PROFILE_ROLES];
3848
+ // Stage from the profile (edit) or the current effective roles (create).
3849
+ const staged = new Map<string, StagedProfileModel>();
3850
+ for (const roleName of PROFILE_ROLES) {
3851
+ const src =
3852
+ existing?.roles[roleName] ?? getEffectiveRole(roleName)?.model;
3853
+ staged.set(roleName, {
3854
+ provider: src?.provider ?? "",
3855
+ id: src?.id ?? "",
3856
+ thinking: src?.thinking ?? "medium",
3857
+ });
3858
+ }
3859
+
3860
+ overlayActive = true;
3861
+ let saved: {
3862
+ activate: boolean;
3863
+ staged: Map<string, StagedProfileModel>;
3864
+ } | null = null;
3865
+ try {
3866
+ saved = await ctx.ui.custom<
3867
+ { activate: boolean; staged: Map<string, StagedProfileModel> } | null
3868
+ >((tui, theme, _kb, done) => {
3869
+ let currentTab = 0;
3870
+ let phase: "model" | "thinking" = "model";
3871
+ let showAll = catalog.scoped.length === 0;
3872
+ let cached: string[] | undefined;
3873
+ const themeList = listThemeOf(theme);
3874
+ const isSubmitTab = () => currentTab >= tabIds.length;
3875
+ const isSaveActivate = () => currentTab === tabIds.length;
3876
+ const buildModelItems = (roleName: string): SelectItem[] => {
3877
+ const st = staged.get(roleName)!;
3878
+ const cur = `${st.provider}/${st.id}`;
3879
+ const src = showAll ? catalog.all : catalog.scoped;
3880
+ return src.map((e) => {
3881
+ const ref = `${e.provider}/${e.id}`;
3882
+ const description: string | undefined =
3883
+ ref === cur
3884
+ ? "● selected"
3885
+ : showAll && !e.inScope
3886
+ ? "all"
3887
+ : undefined;
3888
+ return { value: ref, label: ref, description };
3889
+ });
3890
+ };
3891
+ const modelLists = new Map<string, SelectList>();
3892
+ const thinkingList = new SelectList(
3893
+ VALID_THINKING_LEVELS.map((t) => ({ value: t, label: t })),
3894
+ Math.min(VALID_THINKING_LEVELS.length, 8),
3895
+ themeList,
3896
+ );
3897
+ const getModelList = (roleName: string): SelectList => {
3898
+ let list = modelLists.get(roleName);
3899
+ if (!list) {
3900
+ list = new SelectList(buildModelItems(roleName), 10, themeList);
3901
+ const st = staged.get(roleName)!;
3902
+ const idx = (showAll ? catalog.all : catalog.scoped).findIndex(
3903
+ (e) => `${e.provider}/${e.id}` === `${st.provider}/${st.id}`,
3904
+ );
3905
+ if (idx > 0) list.setSelectedIndex(idx);
3906
+ modelLists.set(roleName, list);
3907
+ }
3908
+ return list;
3909
+ };
3910
+ const rebuildModelLists = () => modelLists.clear();
3911
+ const syncThinkingCursor = (roleName: string) => {
3912
+ const st = staged.get(roleName)!;
3913
+ const idx = VALID_THINKING_LEVELS.indexOf(st.thinking);
3914
+ if (idx >= 0) thinkingList.setSelectedIndex(idx);
3915
+ };
3916
+ const commitModelSelection = (roleName: string) => {
3917
+ const sel = getModelList(roleName).getSelectedItem();
3918
+ if (!sel) return false;
3919
+ const { provider, id } = parseModelRef(sel.value);
3920
+ if (!provider || !id) return false;
3921
+ const st = staged.get(roleName)!;
3922
+ staged.set(roleName, { ...st, provider, id });
3923
+ return true;
3924
+ };
3925
+ const commitThinkingSelection = (roleName: string) => {
3926
+ const sel = thinkingList.getSelectedItem();
3927
+ if (!sel || !isValidThinkingLevel(sel.value)) return false;
3928
+ const st = staged.get(roleName)!;
3929
+ staged.set(roleName, { ...st, thinking: sel.value });
3930
+ return true;
3931
+ };
3932
+ const advanceFromThinking = () => {
3933
+ if (currentTab < tabIds.length) currentTab++;
3934
+ phase = "model";
3935
+ cached = undefined;
3936
+ tui.requestRender();
3937
+ };
3938
+
3939
+ const handleInput = (data: string) => {
3940
+ if (matchesKey(data, Key.escape)) {
3941
+ if (phase === "thinking" && !isSubmitTab()) {
3942
+ phase = "model";
3943
+ cached = undefined;
3944
+ tui.requestRender();
3945
+ return;
3946
+ }
3947
+ done(null);
3948
+ return;
3949
+ }
3950
+ if (
3951
+ matchesKey(data, Key.tab) ||
3952
+ matchesKey(data, Key.right) ||
3953
+ matchesKey(data, Key.shift("tab")) ||
3954
+ matchesKey(data, Key.left)
3955
+ ) {
3956
+ const fwd =
3957
+ matchesKey(data, Key.tab) || matchesKey(data, Key.right);
3958
+ const n = tabIds.length + 2;
3959
+ currentTab = fwd
3960
+ ? (currentTab + 1) % n
3961
+ : (currentTab - 1 + n) % n;
3962
+ phase = "model";
3963
+ cached = undefined;
3964
+ tui.requestRender();
3965
+ return;
3966
+ }
3967
+ // Ctrl+O toggles scoped ↔ all models
3968
+ try {
3969
+ if (matchesKey(data, Key.ctrl("o"))) {
3970
+ showAll = !showAll;
3971
+ rebuildModelLists();
3972
+ cached = undefined;
3973
+ tui.requestRender();
3974
+ return;
3975
+ }
3976
+ } catch (_e) {
3977
+ void _e;
3978
+ }
3979
+ if (isSubmitTab()) {
3980
+ if (matchesKey(data, Key.enter)) {
3981
+ done({ activate: isSaveActivate(), staged: new Map(staged) });
3982
+ }
3983
+ return;
3984
+ }
3985
+ const roleName = tabIds[currentTab];
3986
+ if (matchesKey(data, Key.enter)) {
3987
+ if (phase === "model") {
3988
+ if (!commitModelSelection(roleName)) return;
3989
+ phase = "thinking";
3990
+ syncThinkingCursor(roleName);
3991
+ cached = undefined;
3992
+ tui.requestRender();
3993
+ return;
3994
+ }
3995
+ if (!commitThinkingSelection(roleName)) return;
3996
+ advanceFromThinking();
3997
+ return;
3998
+ }
3999
+ if (phase === "model") getModelList(roleName).handleInput(data);
4000
+ else thinkingList.handleInput(data);
4001
+ cached = undefined;
4002
+ tui.requestRender();
4003
+ };
4004
+
4005
+ const render = (width: number): string[] => {
4006
+ if (cached) return cached;
4007
+ const lines: string[] = [];
4008
+ const W = Math.max(1, width);
4009
+ const wrap = (s: string) => {
4010
+ const out: string[] = [];
4011
+ for (const line of s.split("\n"))
4012
+ out.push(...wrapTextWithAnsi(line, W));
4013
+ return out;
4014
+ };
4015
+ lines.push(theme.fg("accent", "─".repeat(W)));
4016
+ lines.push(
4017
+ ` ${theme.fg(
4018
+ "text",
4019
+ `${isNew ? "New profile" : "Edit profile"} ⚡ ${profileName}`,
4020
+ )}`,
4021
+ );
4022
+ lines.push("");
4023
+ // Tabs row: one block per role + two submit tabs
4024
+ const chips: string[] = [];
4025
+ for (let i = 0; i < tabIds.length; i++) {
4026
+ const roleName = tabIds[i];
4027
+ const st = staged.get(roleName)!;
4028
+ const base = existing?.roles[roleName];
4029
+ const dirty =
4030
+ !base ||
4031
+ `${st.provider}/${st.id}` !== `${base.provider}/${base.id}` ||
4032
+ st.thinking !== base.thinking;
4033
+ const box = dirty ? "■" : "□";
4034
+ const lbl = ` ${box} ${roleIcon(roleName)} ${roleName} `;
4035
+ chips.push(
4036
+ i === currentTab
4037
+ ? theme.bg("selectedBg", theme.fg("text", lbl))
4038
+ : theme.fg(dirty ? "success" : "muted", lbl),
4039
+ );
4040
+ }
4041
+ const saveActivateLbl = " ✓ Save & activate ";
4042
+ const saveOnlyLbl = " 💾 Save only ";
4043
+ chips.push(
4044
+ isSaveActivate()
4045
+ ? theme.bg("selectedBg", theme.fg("text", saveActivateLbl))
4046
+ : theme.fg("success", saveActivateLbl),
4047
+ );
4048
+ chips.push(
4049
+ currentTab === tabIds.length + 1
4050
+ ? theme.bg("selectedBg", theme.fg("text", saveOnlyLbl))
4051
+ : theme.fg("success", saveOnlyLbl),
4052
+ );
4053
+ lines.push(` ${chips.join("")}`);
4054
+ lines.push("");
4055
+ if (isSubmitTab()) {
4056
+ lines.push(
4057
+ ...wrap(
4058
+ ` ${theme.fg(
4059
+ "text",
4060
+ isSaveActivate()
4061
+ ? "Save & activate — Enter saves and switches this session to the profile; Esc cancels"
4062
+ : "Save only — Enter saves without activating; Esc cancels",
4063
+ )}`,
4064
+ ),
4065
+ );
4066
+ lines.push("");
4067
+ for (const roleName of tabIds) {
4068
+ const st = staged.get(roleName)!;
4069
+ lines.push(
4070
+ ...wrap(
4071
+ ` ${theme.fg("muted", `${roleIcon(roleName)} ${roleName}: `)}${theme.fg(
4072
+ "text",
4073
+ st.provider
4074
+ ? `${st.provider}/${st.id} (${st.thinking})`
4075
+ : "(unset)",
4076
+ )}`,
4077
+ ),
4078
+ );
4079
+ }
4080
+ } else {
4081
+ const roleName = tabIds[currentTab];
4082
+ const st = staged.get(roleName)!;
4083
+ const meta = roleMeta.get(roleName)!;
4084
+ lines.push(
4085
+ ...wrap(
4086
+ ` ${theme.fg("text", `${roleIcon(roleName)} ${roleName} — ${meta.description}`)}`,
4087
+ ),
4088
+ );
4089
+ lines.push(
4090
+ ...wrap(
4091
+ ` ${theme.fg(
4092
+ "muted",
4093
+ `staged: ${
4094
+ st.provider
4095
+ ? `${st.provider}/${st.id} (${st.thinking})`
4096
+ : "(unset)"
4097
+ }`,
4098
+ )}`,
4099
+ ),
4100
+ );
4101
+ lines.push("");
4102
+ if (phase === "model") {
4103
+ const scopeLabel = showAll
4104
+ ? `all ${String(catalog.all.length)}${catalog.truncated ? "+" : ""}`
4105
+ : `scoped ${String(catalog.scoped.length)}`;
4106
+ const toggleHint =
4107
+ catalog.scoped.length > 0 && !showAll
4108
+ ? " — Ctrl+O for all"
4109
+ : "";
4110
+ lines.push(
4111
+ ...wrap(
4112
+ ` ${theme.fg(
4113
+ "accent",
4114
+ `Model (${scopeLabel}${toggleHint}) — type to filter:`,
4115
+ )}`,
4116
+ ),
4117
+ );
4118
+ for (const l of getModelList(roleName).render(Math.max(1, W - 2)))
4119
+ lines.push(` ${l}`);
4120
+ } else {
4121
+ lines.push(
4122
+ ...wrap(` ${theme.fg("accent", "Thinking level:")}`),
4123
+ );
4124
+ for (const l of thinkingList.render(Math.max(1, W - 2)))
4125
+ lines.push(` ${l}`);
4126
+ }
4127
+ }
4128
+ lines.push("");
4129
+ lines.push(
4130
+ ...wrap(
4131
+ ` ${theme.fg(
4132
+ "dim",
4133
+ "Tab/←→ roles • ↑↓ navigate • type to filter • Enter select • Esc back/cancel • Ctrl+O scoped/all",
4134
+ )}`,
4135
+ ),
4136
+ );
4137
+ lines.push(theme.fg("accent", "─".repeat(W)));
4138
+ cached = lines;
4139
+ return lines;
4140
+ };
4141
+
4142
+ return {
4143
+ render,
4144
+ invalidate: () => {
4145
+ cached = undefined;
4146
+ },
4147
+ handleInput,
4148
+ };
4149
+ });
4150
+ } finally {
4151
+ overlayActive = false;
4152
+ }
4153
+ if (!saved) {
4154
+ ctx.ui.notify(
4155
+ isNew ? "Profile creation cancelled." : "Profile editor cancelled.",
4156
+ "info",
4157
+ );
4158
+ return;
4159
+ }
4160
+ // Validate against registry (warn-only, matching /role set).
4161
+ try {
4162
+ const reg = (ctx as any)?.modelRegistry;
4163
+ if (reg && typeof reg.find === "function") {
4164
+ for (const [roleName, st] of saved.staged) {
4165
+ if (
4166
+ st.provider &&
4167
+ st.id &&
4168
+ typeof reg.find === "function" &&
4169
+ !reg.find(st.provider, st.id)
4170
+ )
4171
+ ctx.ui.notify(
4172
+ `Warning: ${st.provider}/${st.id} (${roleName}) not in model registry — saved anyway.`,
4173
+ "warning",
4174
+ );
4175
+ }
4176
+ }
4177
+ } catch (_e) {
4178
+ void _e;
4179
+ }
4180
+ // Materialize: create or update the profile, then persist.
4181
+ let profile = existing;
4182
+ if (!profile) {
4183
+ profile = { name: profileName, description: "", roles: {} };
4184
+ profilesConfig.profiles.push(profile);
4185
+ }
4186
+ profile.roles = {};
4187
+ for (const roleName of PROFILE_ROLES) {
4188
+ const st = saved.staged.get(roleName);
4189
+ if (st && st.provider && st.id)
4190
+ profile.roles[roleName] = {
4191
+ provider: st.provider,
4192
+ id: st.id,
4193
+ thinking: st.thinking,
4194
+ };
4195
+ }
4196
+ try {
4197
+ saveProfiles(profilesConfig, "user");
4198
+ } catch (e: any) {
4199
+ ctx.ui.notify(
4200
+ `Profile updated but file save failed: ${String(e?.message || e)}`,
4201
+ "warning",
4202
+ );
4203
+ return;
4204
+ }
4205
+ invalidateEffectiveRoles();
4206
+ updateStatus(ctx as any);
4207
+ if (saved.activate) {
4208
+ await setActiveProfile(ctx as any, profile.name, { explicit: true });
4209
+ } else {
4210
+ ctx.ui.notify(
4211
+ `Profile ⚡ ${profile.name} saved. Activate with /profile use ${profile.name}.`,
4212
+ "info",
4213
+ );
4214
+ }
4215
+ }
4216
+
4217
+ // ── Model profiles command ───────────────────────────────────────
4218
+
4219
+ pi.registerCommand("profile", {
4220
+ description:
4221
+ "Switch or manage model profiles (named role-model combinations) — opens picker UI",
4222
+ handler: async (args, ctx) => {
4223
+ const cwd = (ctx as any)?.cwd as string | undefined;
4224
+ ensureRoleConfig(cwd);
4225
+ ensureProfilesConfig(cwd);
4226
+ const parts = String(args || "")
4227
+ .trim()
4228
+ .split(/\s+/)
4229
+ .filter(Boolean);
4230
+ const sub = (parts[0] || "").toLowerCase();
4231
+ const rest = parts.slice(1);
4232
+
4233
+ const persistAndNotify = (msg: string): boolean => {
4234
+ try {
4235
+ saveProfiles(profilesConfig, "user");
4236
+ } catch (e: any) {
4237
+ ctx.ui.notify(
4238
+ `Profile updated but file save failed: ${String(e?.message || e)}`,
4239
+ "warning",
4240
+ );
4241
+ return false;
4242
+ }
4243
+ invalidateEffectiveRoles();
4244
+ updateStatus(ctx as any);
4245
+ ctx.ui.notify(msg, "info");
4246
+ return true;
4247
+ };
4248
+
4249
+ // Bare /profile → picker UI (headless → text summary).
4250
+ if (!sub) {
4251
+ if ((ctx as any)?.hasUI) {
4252
+ await openProfilePicker(ctx as any);
4253
+ return;
4254
+ }
4255
+ ctx.ui.notify(
4256
+ formatProfilesForDisplay(
4257
+ profilesConfig,
4258
+ activeProfileName,
4259
+ currentDefaultProfileName(cwd),
4260
+ ),
4261
+ "info",
4262
+ );
4263
+ return;
4264
+ }
4265
+
4266
+ if (sub === "list" || (sub === "show" && !rest[0])) {
4267
+ ctx.ui.notify(
4268
+ formatProfilesForDisplay(
4269
+ profilesConfig,
4270
+ activeProfileName,
4271
+ currentDefaultProfileName(cwd),
4272
+ ),
4273
+ "info",
4274
+ );
4275
+ return;
4276
+ }
4277
+
4278
+ if (sub === "show") {
4279
+ const p = getProfile(profilesConfig, rest[0]);
4280
+ if (!p) {
4281
+ ctx.ui.notify(`Unknown profile "${rest[0]}".`, "warning");
4282
+ return;
4283
+ }
4284
+ ctx.ui.notify(describeProfile(p), "info");
4285
+ return;
4286
+ }
4287
+
4288
+ if (sub === "use" || sub === "activate") {
4289
+ const name = rest[0];
4290
+ if (
4291
+ !name ||
4292
+ (name.toLowerCase() !== "none" &&
4293
+ name.toLowerCase() !== "off" &&
4294
+ !getProfile(profilesConfig, name))
4295
+ ) {
4296
+ ctx.ui.notify(
4297
+ `Usage: /profile use <name|none>. Available: ${
4298
+ profilesConfig.profiles.map((p) => p.name).join(", ") ||
4299
+ "(none — /profile create <name>)"
4300
+ }`,
4301
+ "info",
4302
+ );
4303
+ return;
4304
+ }
4305
+ await setActiveProfile(
4306
+ ctx as any,
4307
+ name.toLowerCase() === "none" || name.toLowerCase() === "off"
4308
+ ? null
4309
+ : name,
4310
+ { explicit: true },
4311
+ );
4312
+ return;
4313
+ }
4314
+
4315
+ if (sub === "create" || sub === "new" || sub === "add") {
4316
+ const rawName = rest[0];
4317
+ if (!rawName) {
4318
+ ctx.ui.notify("Usage: /profile create <name>", "info");
4319
+ return;
4320
+ }
4321
+ const check = validateProfileName(
4322
+ rawName,
4323
+ profilesConfig.profiles.map((p) => p.name),
4324
+ );
4325
+ if (!check.ok) {
4326
+ ctx.ui.notify(`Cannot create profile: ${check.error}`, "warning");
4327
+ return;
4328
+ }
4329
+ if ((ctx as any)?.hasUI) {
4330
+ await openProfileEditor(ctx as any, { createName: check.value });
4331
+ return;
4332
+ }
4333
+ // Headless: create seeded from the current effective roles.
4334
+ const seed = buildProfileSeed(check.value);
4335
+ profilesConfig.profiles.push(seed);
4336
+ persistAndNotify(
4337
+ `Profile ⚡ ${seed.name} created (seeded from the current effective models). Activate with /profile use ${seed.name}.`,
4338
+ );
4339
+ return;
4340
+ }
4341
+
4342
+ if (sub === "edit" || sub === "config") {
4343
+ const name = rest[0];
4344
+ if (!name) {
4345
+ ctx.ui.notify("Usage: /profile edit <name>", "info");
4346
+ return;
4347
+ }
4348
+ const p = getProfile(profilesConfig, name);
4349
+ if (!p) {
4350
+ ctx.ui.notify(
4351
+ `Unknown profile "${name}". Available: ${
4352
+ profilesConfig.profiles.map((q) => q.name).join(", ") || "(none)"
4353
+ }`,
4354
+ "warning",
4355
+ );
4356
+ return;
4357
+ }
4358
+ if (!(ctx as any)?.hasUI) {
4359
+ ctx.ui.notify(describeProfile(p) + "\n\nEditor needs UI.", "info");
4360
+ return;
4361
+ }
4362
+ await openProfileEditor(ctx as any, { name: p.name });
4363
+ return;
4364
+ }
4365
+
4366
+ if (sub === "rename" || sub === "mv") {
4367
+ const [oldName, newName] = rest;
4368
+ if (!oldName || !newName) {
4369
+ ctx.ui.notify("Usage: /profile rename <old> <new>", "info");
4370
+ return;
4371
+ }
4372
+ const p = getProfile(profilesConfig, oldName);
4373
+ if (!p) {
4374
+ ctx.ui.notify(`Unknown profile "${oldName}".`, "warning");
4375
+ return;
4376
+ }
4377
+ const check = validateProfileName(
4378
+ newName,
4379
+ profilesConfig.profiles
4380
+ .filter((q) => q !== p)
4381
+ .map((q) => q.name),
4382
+ );
4383
+ if (!check.ok) {
4384
+ ctx.ui.notify(`Cannot rename: ${check.error}`, "warning");
4385
+ return;
4386
+ }
4387
+ const wasActive =
4388
+ activeProfileName?.toLowerCase() === p.name.toLowerCase();
4389
+ p.name = check.value;
4390
+ if (wasActive) activeProfileName = p.name;
4391
+ invalidateEffectiveRoles();
4392
+ persistAndNotify(
4393
+ `Profile renamed → ⚡ ${p.name}${
4394
+ wasActive ? " (still active in this session)" : ""
4395
+ }`,
4396
+ );
4397
+ const def = readDefaultProfileSetting(
4398
+ cwd ?? process.cwd(),
4399
+ getAgentDir(),
4400
+ );
4401
+ if (def.value && def.value.toLowerCase() === oldName.toLowerCase()) {
4402
+ ctx.ui.notify(
4403
+ `workflow.defaultProfile still points at "${def.value}" — update with /profile default ${p.name}${
4404
+ def.sources.effective === "project" ? " project" : ""
4405
+ }`,
4406
+ "warning",
4407
+ );
4408
+ }
4409
+ return;
4410
+ }
4411
+
4412
+ if (sub === "delete" || sub === "remove" || sub === "rm") {
4413
+ const name = rest[0];
4414
+ if (!name) {
4415
+ ctx.ui.notify("Usage: /profile delete <name>", "info");
4416
+ return;
4417
+ }
4418
+ const p = getProfile(profilesConfig, name);
4419
+ if (!p) {
4420
+ ctx.ui.notify(`Unknown profile "${name}".`, "warning");
4421
+ return;
4422
+ }
4423
+ if ((ctx as any)?.hasUI) {
4424
+ const confirmed = await confirmProfileDelete(ctx as any, p);
4425
+ if (!confirmed) {
4426
+ ctx.ui.notify("Deletion cancelled.", "info");
4427
+ return;
4428
+ }
4429
+ }
4430
+ const wasActive =
4431
+ activeProfileName?.toLowerCase() === p.name.toLowerCase();
4432
+ profilesConfig.profiles = profilesConfig.profiles.filter(
4433
+ (q) => q !== p,
4434
+ );
4435
+ if (wasActive) {
4436
+ activeProfileName = null;
4437
+ invalidateEffectiveRoles();
4438
+ }
4439
+ const savedOk = persistAndNotify(
4440
+ `Profile ⚡ ${p.name} deleted.${
4441
+ wasActive ? " It was active — default roles flow restored." : ""
4442
+ }`,
4443
+ );
4444
+ if (savedOk && wasActive && workflowMode) {
4445
+ try {
4446
+ await applyModeModel(ctx as any);
4447
+ updateStatus(ctx as any);
4448
+ } catch (_e) {
4449
+ void _e;
4450
+ }
4451
+ }
4452
+ return;
4453
+ }
4454
+
4455
+ if (sub === "default") {
4456
+ // /profile default [name|none] [global|project]
4457
+ const target = rest.find(
4458
+ (t) => t.toLowerCase() !== "global" && t.toLowerCase() !== "project",
4459
+ );
4460
+ const scope: "global" | "project" = rest
4461
+ .map((t) => t.toLowerCase())
4462
+ .includes("project")
4463
+ ? "project"
4464
+ : "global";
4465
+ const paths = defaultProfileSettingPaths(
4466
+ cwd ?? process.cwd(),
4467
+ getAgentDir(),
4468
+ );
4469
+ if (!target) {
4470
+ const resolved = readDefaultProfileSetting(
4471
+ cwd ?? process.cwd(),
4472
+ getAgentDir(),
4473
+ );
4474
+ ctx.ui.notify(
4475
+ [
4476
+ `Default profile (workflow.defaultProfile): ${
4477
+ resolved.value ?? "(none)"
4478
+ } (${resolved.sources.effective})`,
4479
+ `Global settings: ${paths.global}`,
4480
+ `Project settings: ${paths.project}`,
4481
+ "New sessions without an explicit /profile use start on the default profile.",
4482
+ "Usage: /profile default <name|none> [global|project]",
4483
+ ].join("\n"),
4484
+ "info",
4485
+ );
4486
+ return;
4487
+ }
4488
+ const value =
4489
+ target.toLowerCase() === "none" || target.toLowerCase() === "off"
4490
+ ? null
4491
+ : target;
4492
+ if (value && !getProfile(profilesConfig, value)) {
4493
+ ctx.ui.notify(
4494
+ `Unknown profile "${value}". Available: ${
4495
+ profilesConfig.profiles.map((q) => q.name).join(", ") || "(none)"
4496
+ }`,
4497
+ "warning",
4498
+ );
4499
+ return;
4500
+ }
4501
+ const res = writeDefaultProfileSetting(
4502
+ scope,
4503
+ cwd ?? process.cwd(),
4504
+ getAgentDir(),
4505
+ value,
4506
+ );
4507
+ if (!res.ok) {
4508
+ ctx.ui.notify(
4509
+ `Could not write default profile: ${res.error ?? "unknown error"}`,
4510
+ "error",
4511
+ );
4512
+ return;
4513
+ }
4514
+ ctx.ui.notify(
4515
+ value
4516
+ ? `Default profile → ⚡ ${value} (${scope} settings)\nNew sessions without an explicit /profile use start on it.`
4517
+ : `Default profile cleared (${scope} settings).`,
4518
+ "info",
4519
+ );
4520
+ updateStatus(ctx as any);
4521
+ return;
4522
+ }
4523
+
4524
+ // Bare profile name → show detail (mirrors /role <name>).
4525
+ const named = getProfile(profilesConfig, sub);
4526
+ if (named) {
4527
+ ctx.ui.notify(describeProfile(named), "info");
4528
+ return;
4529
+ }
4530
+ ctx.ui.notify(PROFILE_USAGE, "info");
4531
+ },
4532
+ getArgumentCompletions: (prefix: string) => {
4533
+ ensureProfilesConfig();
4534
+ const subs = [
4535
+ { value: "list", label: "list", description: "show all profiles" },
4536
+ {
4537
+ value: "use",
4538
+ label: "use <name|none>",
4539
+ description: "activate/deactivate for this session",
4540
+ },
4541
+ {
4542
+ value: "create",
4543
+ label: "create <name>",
4544
+ description: "create a profile (seeded from current models)",
4545
+ },
4546
+ {
4547
+ value: "edit",
4548
+ label: "edit <name>",
4549
+ description: "open the tabbed model editor",
4550
+ },
4551
+ {
4552
+ value: "rename",
4553
+ label: "rename <old> <new>",
4554
+ description: "rename a profile",
4555
+ },
4556
+ {
4557
+ value: "delete",
4558
+ label: "delete <name>",
4559
+ description: "delete a profile",
4560
+ },
4561
+ {
4562
+ value: "default",
4563
+ label: "default [name|none] [global|project]",
4564
+ description: "default profile for new sessions",
4565
+ },
4566
+ {
4567
+ value: "show",
4568
+ label: "show <name>",
4569
+ description: "show one profile's models",
4570
+ },
4571
+ ];
4572
+ const lower = String(prefix || "").toLowerCase();
4573
+ if (lower.includes(" ")) {
4574
+ // Completing an argument after a subcommand → profile names where relevant.
4575
+ const cmd = lower.trim().split(/\s+/)[0];
4576
+ const wantsNames =
4577
+ cmd === "use" ||
4578
+ cmd === "edit" ||
4579
+ cmd === "delete" ||
4580
+ cmd === "show" ||
4581
+ cmd === "default";
4582
+ if (!wantsNames) return null;
4583
+ const last = lower.trim().split(/\s+/).pop() ?? "";
4584
+ const names = profilesConfig.profiles
4585
+ .filter((p) => p.name.toLowerCase().startsWith(last))
4586
+ .map((p) => ({
4587
+ value: p.name,
4588
+ label: p.name,
4589
+ description: p.description || undefined,
4590
+ }));
4591
+ return cmd === "use" || cmd === "default"
4592
+ ? [
4593
+ ...names,
4594
+ { value: "none", label: "none", description: "no profile" },
4595
+ ]
4596
+ : names;
4597
+ }
4598
+ const matching = subs.filter((s) => s.value.startsWith(lower));
4599
+ if (matching.length > 0) return matching;
4600
+ const names = profilesConfig.profiles
4601
+ .filter((p) => p.name.toLowerCase().startsWith(lower))
4602
+ .map((p) => ({
4603
+ value: p.name,
4604
+ label: p.name,
4605
+ description: p.description || undefined,
4606
+ }));
4607
+ return names.length ? names : null;
4608
+ },
4609
+ });
4610
+
4611
+ // ── Role config screen (tabbed picker: one block per role) ─────────
4612
+ //
4613
+ // One overlay with a tab per role (built-ins first, then customs) plus
4614
+ // a Submit tab. Each role tab has two phases: pick model (from the
4615
+ // connected providers — scoped models first, Ctrl+O for all) then pick
4616
+ // thinking level. Selections stage locally; Submit saves to roles.json.
4617
+
4618
+ interface StagedRoleModel {
4619
+ provider: string;
4620
+ id: string;
4621
+ thinking: ThinkingLevel;
4622
+ }
4623
+
4624
+ interface RoleCatalogEntry {
4625
+ provider: string;
4626
+ id: string;
4627
+ inScope: boolean;
4628
+ }
4629
+
4630
+ function collectRoleCatalog(ctx: ExtensionContext): {
4631
+ scoped: RoleCatalogEntry[];
4632
+ all: RoleCatalogEntry[];
4633
+ truncated: boolean;
4634
+ } {
4635
+ const scoped: RoleCatalogEntry[] = [];
4636
+ try {
4637
+ const raw = ((ctx as any)?.scopedModels ?? []) as any[];
4638
+ for (const s of raw) {
4639
+ const m = s?.model ?? s;
4640
+ if (m && typeof m.provider === "string" && typeof m.id === "string")
4641
+ scoped.push({ provider: m.provider, id: m.id, inScope: true });
4642
+ }
4643
+ } catch (_e) {
4644
+ void _e;
4645
+ }
4646
+ let available: any[] = [];
4647
+ try {
4648
+ const reg = (ctx as any)?.modelRegistry;
4649
+ if (reg && typeof reg.getAvailable === "function")
4650
+ available = [...reg.getAvailable()];
4651
+ else if (reg && typeof reg.getAll === "function")
4652
+ available = [...reg.getAll()];
4653
+ } catch (_e) {
4654
+ void _e;
4655
+ }
4656
+ const seen = new Set(scoped.map((s) => `${s.provider}/${s.id}`));
4657
+ const rest: RoleCatalogEntry[] = [];
4658
+ for (const m of available) {
4659
+ if (!m || typeof m.provider !== "string" || typeof m.id !== "string")
4660
+ continue;
4661
+ const key = `${m.provider}/${m.id}`;
4662
+ if (seen.has(key)) continue;
4663
+ seen.add(key);
4664
+ rest.push({ provider: m.provider, id: m.id, inScope: false });
4665
+ }
4666
+ const CAP = 200;
4667
+ const all = [...scoped, ...rest];
4668
+ return {
4669
+ scoped,
4670
+ all: all.slice(0, CAP),
4671
+ truncated: all.length > CAP,
4672
+ };
4673
+ }
4674
+
4675
+ function orderedRoleTabs(): Role[] {
4676
+ // Built-in roles first in their declared order, then custom roles.
4677
+ // Driven by BUILT_IN_ROLES so adding a role never needs a second edit here.
4678
+ const rank = (name: string) => {
4679
+ const i = (BUILT_IN_ROLES as readonly string[]).indexOf(
4680
+ name.toLowerCase(),
4681
+ );
4682
+ return i === -1 ? BUILT_IN_ROLES.length : i;
4683
+ };
4684
+ return [...roleConfig.roles].sort((a, b) => rank(a.name) - rank(b.name));
4685
+ }
4686
+
4687
+ async function openRolePicker(ctx: ExtensionContext): Promise<void> {
4688
+ const tabs = orderedRoleTabs();
4689
+ if (tabs.length === 0) {
4690
+ ctx.ui.notify("No roles configured — /role reset to reseed.", "warning");
4691
+ return;
4692
+ }
4693
+ const catalog = collectRoleCatalog(ctx);
4694
+ if (catalog.all.length === 0) {
4695
+ ctx.ui.notify(
4696
+ "No models available from connected providers — connect a provider first, or use /role set <role> <provider/model> [thinking].",
4697
+ "warning",
4698
+ );
4699
+ return;
4700
+ }
4701
+ // Stage current values so the picker opens on the active selection.
4702
+ const staged = new Map<string, StagedRoleModel>();
4703
+ for (const r of tabs) staged.set(r.name, { ...r.model });
4704
+
4705
+ overlayActive = true;
4706
+ let saved: Map<string, StagedRoleModel> | null = null;
4707
+ try {
4708
+ saved = await ctx.ui.custom<Map<string, StagedRoleModel> | null>(
4709
+ (tui, theme, _kb, done) => {
4710
+ let currentTab = 0; // tabs.length = Submit
4711
+ let phase: "model" | "thinking" = "model";
4712
+ let showAll = catalog.scoped.length === 0;
4713
+ let cached: string[] | undefined;
4714
+ // One SelectList per role tab for models + one shared for thinking,
4715
+ // so filter text + cursor survive tab switches.
4716
+ const listTheme = {
4717
+ selectedPrefix: (t: string) => theme.fg("accent", t),
4718
+ selectedText: (t: string) => theme.fg("accent", t),
4719
+ description: (t: string) => theme.fg("muted", t),
4720
+ scrollInfo: (t: string) => theme.fg("dim", t),
4721
+ noMatch: (t: string) => theme.fg("warning", t),
4722
+ };
3089
4723
  const currentRef = (role: Role) =>
3090
4724
  `${role.model.provider}/${role.model.id}`;
3091
4725
  const buildModelItems = (role: Role): SelectItem[] => {
@@ -3240,6 +4874,16 @@ export default function workflowExtension(pi: ExtensionAPI) {
3240
4874
  return out;
3241
4875
  };
3242
4876
  lines.push(theme.fg("accent", "─".repeat(W)));
4877
+ // Profile notice: /role edits the BASE roles; an active profile
4878
+ // overlays models on top (see effectiveRoles).
4879
+ if (activeProfileName) {
4880
+ lines.push(
4881
+ ...wrap(
4882
+ ` ${theme.fg("warning", `Profile ⚡ ${activeProfileName} active — editing BASE roles; profile overrides apply on top. /profile edit ${activeProfileName} to change the profile.`)}`,
4883
+ ),
4884
+ );
4885
+ lines.push("");
4886
+ }
3243
4887
  // Tabs row: one block per role + Submit
3244
4888
  const chips: string[] = [];
3245
4889
  for (let i = 0; i < tabs.length; i++) {
@@ -3475,7 +5119,9 @@ export default function workflowExtension(pi: ExtensionAPI) {
3475
5119
  // Tasks whose agent has no explicit model use the explorer role's
3476
5120
  // single model. Telemetry lands in details.
3477
5121
  ensureRoleConfig(cwd);
3478
- const explorerRole = getRole(roleConfig, "explorer");
5122
+ ensureProfilesConfig(cwd);
5123
+ // Active profile's explorer model when a profile is active.
5124
+ const explorerRole = getEffectiveRole("explorer");
3479
5125
  const routedMeta: { role: string; model: string; reason: string }[] =
3480
5126
  new Array(jobs.length);
3481
5127
  const routedAgents: (AgentConfig | undefined)[] = new Array(jobs.length);
@@ -4507,10 +6153,12 @@ export default function workflowExtension(pi: ExtensionAPI) {
4507
6153
  pi.on("session_start", async (event, ctx) => {
4508
6154
  // Roles are file-backed; load them up-front so every read path (status,
4509
6155
  // band label, reviewer runtime) sees the real config even in a fresh
4510
- // session that has no persisted workflow entry yet.
6156
+ // session that has no persisted workflow entry yet. Profiles likewise.
4511
6157
  try {
4512
6158
  ensureRoleConfig((ctx as any)?.cwd);
4513
6159
  notifyRoleConfigFallback(ctx as ExtensionContext);
6160
+ ensureProfilesConfig((ctx as any)?.cwd);
6161
+ notifyProfilesFallback(ctx as ExtensionContext);
4514
6162
  } catch (_e) {
4515
6163
  void _e;
4516
6164
  }
@@ -4540,13 +6188,42 @@ export default function workflowExtension(pi: ExtensionAPI) {
4540
6188
  toolsBeforePlanMode = d.toolsBeforePlanMode;
4541
6189
  currentPlanFile = d.planFile;
4542
6190
  // restore role state (file config is source of truth for roles;
4543
- // session entry carries the latest activeRole)
6191
+ // session entry carries the latest activeRole) and the session's
6192
+ // own profile activation
4544
6193
  try {
4545
6194
  ensureRoleConfig((ctx as any)?.cwd);
6195
+ ensureProfilesConfig((ctx as any)?.cwd);
4546
6196
  const rd = (d as any).role;
4547
6197
  if (rd && typeof rd === "object") {
4548
6198
  if (typeof rd.activeRole === "string" || rd.activeRole === null)
4549
6199
  roleConfig.activeRole = rd.activeRole;
6200
+ // Profiles: an explicit pick restores exactly (name or none);
6201
+ // entries without an explicit choice (fresh / pre-profile /
6202
+ // default-seeded) re-derive workflow.defaultProfile below so
6203
+ // a changed default still applies on resume.
6204
+ profileExplicit = rd.profileExplicit === true;
6205
+ if (profileExplicit) {
6206
+ activeProfileName =
6207
+ typeof rd.activeProfile === "string"
6208
+ ? rd.activeProfile
6209
+ : null;
6210
+ if (
6211
+ activeProfileName &&
6212
+ !getProfile(profilesConfig, activeProfileName)
6213
+ ) {
6214
+ try {
6215
+ (ctx as any).ui?.notify?.(
6216
+ `Profiles: "${activeProfileName}" no longer exists — default roles flow active.`,
6217
+ "warning",
6218
+ );
6219
+ } catch (_e) {
6220
+ void _e;
6221
+ }
6222
+ activeProfileName = null;
6223
+ }
6224
+ } else {
6225
+ activeProfileName = null;
6226
+ }
4550
6227
  }
4551
6228
  } catch (_e) {
4552
6229
  void _e;
@@ -4628,6 +6305,39 @@ export default function workflowExtension(pi: ExtensionAPI) {
4628
6305
  } catch (_e) {
4629
6306
  void _e;
4630
6307
  }
6308
+ // Default-profile seeding: sessions without an explicit choice follow
6309
+ // workflow.defaultProfile, re-read at every start/resume so a changed
6310
+ // default applies. Sessions that picked explicitly (or explicitly chose
6311
+ // none) never see the default.
6312
+ if (!profileExplicit) {
6313
+ try {
6314
+ const def = readDefaultProfileSetting(
6315
+ (ctx as any).cwd ?? process.cwd(),
6316
+ getAgentDir(),
6317
+ ).value;
6318
+ if (def) {
6319
+ ensureProfilesConfig((ctx as any)?.cwd);
6320
+ const p = getProfile(profilesConfig, def);
6321
+ if (p) {
6322
+ if (activeProfileName !== p.name) {
6323
+ activeProfileName = p.name;
6324
+ invalidateEffectiveRoles();
6325
+ }
6326
+ } else {
6327
+ try {
6328
+ (ctx as any).ui?.notify?.(
6329
+ `Profiles: default profile "${def}" not found in profiles.json — skipping.`,
6330
+ "warning",
6331
+ );
6332
+ } catch (_e) {
6333
+ void _e;
6334
+ }
6335
+ }
6336
+ }
6337
+ } catch (_e) {
6338
+ void _e;
6339
+ }
6340
+ }
4631
6341
  // auto-enable plan mode if flag set at startup (support both --plan and --workflow-plan)
4632
6342
  const flagPlan =
4633
6343
  (pi as any).getFlagValue?.("plan") ??