@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.
- package/README.md +45 -2
- package/index.ts +1824 -114
- package/package.json +3 -2
- 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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
1516
|
-
const role = targetName ? getRole(
|
|
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(
|
|
3046
|
+
ctx.ui.notify(formatRolesDisplayWithProfile(), "info");
|
|
2786
3047
|
return;
|
|
2787
3048
|
}
|
|
2788
3049
|
|
|
2789
3050
|
if (sub === "list" || sub === "show") {
|
|
2790
|
-
ctx.ui.notify(
|
|
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
|
-
|
|
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
|
-
// ──
|
|
3238
|
+
// ── Model profiles command + management UI ───────────────────────
|
|
2978
3239
|
//
|
|
2979
|
-
//
|
|
2980
|
-
//
|
|
2981
|
-
//
|
|
2982
|
-
//
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
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
|
-
|
|
3015
|
-
|
|
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
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
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
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
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
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
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
|
-
|
|
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") ??
|