@dieulc/workflow 0.5.1 → 0.5.2
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 +3 -1
- package/commit-policy.ts +103 -0
- package/index.ts +41 -110
- package/package.json +1 -1
- package/profiles.ts +208 -1
- package/roles.ts +142 -0
- package/utils.ts +60 -0
package/README.md
CHANGED
|
@@ -69,7 +69,9 @@ A profile is a **named combination of role model settings** — one `{provider,
|
|
|
69
69
|
|
|
70
70
|
**Why**: mix models from different providers in one workflow (frontier planner + cheap explorer), keep one profile per provider to control budget per provider, run concurrent sessions on different providers to work around per-provider concurrency limits, and exploit cheaper starter plans across several providers.
|
|
71
71
|
|
|
72
|
-
Profile config lives in `~/.pi/agent/profiles.json` v1 (user) with optional per-project override at `<cwd>/.pi/profiles.json` (per-profile replace, project wins). Names are case-insensitively unique, ≤ 40 chars, no whitespace (keep them CLI-addressable), and `none`/`auto` are reserved. A missing file is the normal empty state; a corrupt or wrong-version file is never rewritten (warning + in-memory empty config).
|
|
72
|
+
Profile config lives in `~/.pi/agent/profiles.json` v1 (user) with optional per-project override at `<cwd>/.pi/profiles.json` (per-profile replace, project wins). Names are case-insensitively unique, ≤ 40 chars, no whitespace (keep them CLI-addressable), and `none`/`auto`/`off` are reserved (`off` is a `/profile use` deactivate keyword, so a profile can never be named it). A missing file is the normal empty state; a corrupt or wrong-version file is never rewritten (warning + in-memory empty config).
|
|
73
|
+
|
|
74
|
+
**Argument completion** follows pi's full-argument contract (the same one pi's `/mcp` uses): accepting a suggestion replaces the entire typed argument text, so completing `/profile use Syn-␣` inserts `/profile use Syn-Deepseek-…` with the subcommand intact — it never collapses to `/profile <name>`. The dropdown closes (and the first Enter submits) once the typed token is already an exact, unique match. This applies positionally to `/profile` (`use|edit|show|delete|rename <old>|default <name> [global|project]`), `/commit-policy` (`never|ask|allow [global|project]`) and `/role` (`set <role> <provider/model> [thinking]`, `use <role|auto>`, `remove <name>`).
|
|
73
75
|
|
|
74
76
|
## Review Mode
|
|
75
77
|
|
package/commit-policy.ts
CHANGED
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
|
|
24
24
|
import * as fs from "node:fs";
|
|
25
25
|
import * as path from "node:path";
|
|
26
|
+
import type { ArgumentCompletion } from "./utils.ts";
|
|
27
|
+
import { joinArgumentValue, suppressExactArgumentMatch } from "./utils.ts";
|
|
26
28
|
|
|
27
29
|
/** Pi's project config directory. Mirrors WORKFLOW_CONFIG_DIR without importing review.ts (which pulls provider modules). */
|
|
28
30
|
export const COMMIT_CONFIG_DIR = ".pi";
|
|
@@ -526,3 +528,104 @@ export function writeCommitPolicyConfig(
|
|
|
526
528
|
};
|
|
527
529
|
}
|
|
528
530
|
}
|
|
531
|
+
|
|
532
|
+
// ── Argument completions (/commit-policy) ────────────────────────────
|
|
533
|
+
|
|
534
|
+
/** One /commit-policy subcommand with its usage hint. */
|
|
535
|
+
const COMMIT_POLICY_SUBS: readonly {
|
|
536
|
+
name: string;
|
|
537
|
+
label: string;
|
|
538
|
+
description: string;
|
|
539
|
+
}[] = [
|
|
540
|
+
{
|
|
541
|
+
name: "never",
|
|
542
|
+
label: "never",
|
|
543
|
+
description: "agent cannot git add/commit/push — you commit (or /commit)",
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
name: "ask",
|
|
547
|
+
label: "ask",
|
|
548
|
+
description: "agent may commit; each git commit/push asks first",
|
|
549
|
+
},
|
|
550
|
+
{
|
|
551
|
+
name: "allow",
|
|
552
|
+
label: "allow",
|
|
553
|
+
description: "agent may commit freely (per-step during plan execution)",
|
|
554
|
+
},
|
|
555
|
+
];
|
|
556
|
+
|
|
557
|
+
const COMMIT_POLICY_SCOPES: readonly {
|
|
558
|
+
token: string;
|
|
559
|
+
description: string;
|
|
560
|
+
}[] = [
|
|
561
|
+
{ token: "global", description: "write to ~/.pi/agent/settings.json" },
|
|
562
|
+
{ token: "project", description: "write to <cwd>/.pi/settings.json" },
|
|
563
|
+
];
|
|
564
|
+
|
|
565
|
+
/** commit-policy subcommands take an optional scope → trailing-space values. */
|
|
566
|
+
const COMMIT_POLICY_MODE_TOKENS: ReadonlySet<string> = new Set([
|
|
567
|
+
"never",
|
|
568
|
+
"ask",
|
|
569
|
+
"allow",
|
|
570
|
+
]);
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Argument completions for `/commit-policy` (pure; unit-tested in
|
|
574
|
+
* commit-policy.test.ts).
|
|
575
|
+
*
|
|
576
|
+
* Follows the pi-tui full-argument contract (see `joinArgumentValue` and the
|
|
577
|
+
* /mcp extension): the first token completes a mode, the second token
|
|
578
|
+
* completes the write scope, and every returned `value` is the complete
|
|
579
|
+
* replacement for the typed argument text (`"ask project"`, never
|
|
580
|
+
* `"project"`). Returns null when nothing useful can be offered, including
|
|
581
|
+
* when the typed token is already an exact unique match (exact-match
|
|
582
|
+
* suppression — the dropdown closes so the first Enter submits).
|
|
583
|
+
*/
|
|
584
|
+
export function commitPolicyArgumentCompletions(
|
|
585
|
+
argumentPrefix: string,
|
|
586
|
+
): ArgumentCompletion[] | null {
|
|
587
|
+
const raw = String(argumentPrefix ?? "");
|
|
588
|
+
const join = (token: string) => joinArgumentValue(raw, token);
|
|
589
|
+
|
|
590
|
+
// ── Subcommand position: no whitespace typed yet ────────────────────
|
|
591
|
+
if (!raw.trim() || !/\s/.test(raw)) {
|
|
592
|
+
const lower = raw.trim().toLowerCase();
|
|
593
|
+
const matching = COMMIT_POLICY_SUBS.filter((s) =>
|
|
594
|
+
s.name.startsWith(lower),
|
|
595
|
+
);
|
|
596
|
+
if (matching.length === 0) return null;
|
|
597
|
+
return matching.map((s) => ({
|
|
598
|
+
value: `${s.name} `,
|
|
599
|
+
label: s.label,
|
|
600
|
+
description: s.description,
|
|
601
|
+
}));
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// ── Argument position: mode already committed → complete the scope ──
|
|
605
|
+
const tokens = raw.trim().split(/\s+/).filter(Boolean);
|
|
606
|
+
const mode = (tokens[0] ?? "").toLowerCase();
|
|
607
|
+
if (!COMMIT_POLICY_MODE_TOKENS.has(mode)) return null;
|
|
608
|
+
const endsWithWs = /\s$/.test(raw);
|
|
609
|
+
const partial = endsWithWs ? "" : (tokens[tokens.length - 1] ?? "");
|
|
610
|
+
const items: ArgumentCompletion[] = COMMIT_POLICY_SCOPES.flatMap((s) =>
|
|
611
|
+
s.token.startsWith(partial.toLowerCase())
|
|
612
|
+
? [
|
|
613
|
+
{
|
|
614
|
+
value: join(s.token),
|
|
615
|
+
label: s.token,
|
|
616
|
+
description: s.description,
|
|
617
|
+
},
|
|
618
|
+
]
|
|
619
|
+
: [],
|
|
620
|
+
);
|
|
621
|
+
if (items.length === 0) return null;
|
|
622
|
+
if (
|
|
623
|
+
suppressExactArgumentMatch(
|
|
624
|
+
items.map((i) => i.label),
|
|
625
|
+
partial,
|
|
626
|
+
)
|
|
627
|
+
) {
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
return items;
|
|
631
|
+
}
|
package/index.ts
CHANGED
|
@@ -94,6 +94,7 @@ import {
|
|
|
94
94
|
COMMIT_FLOW_TTL_MS,
|
|
95
95
|
armCommitAuthorization,
|
|
96
96
|
commitConfigPaths,
|
|
97
|
+
commitPolicyArgumentCompletions,
|
|
97
98
|
decideCommitGate,
|
|
98
99
|
describeCommitPolicyMode,
|
|
99
100
|
isAuthorizationActive,
|
|
@@ -122,6 +123,7 @@ import {
|
|
|
122
123
|
defaultRoleConfig,
|
|
123
124
|
loadRoleConfigDetailed,
|
|
124
125
|
rolesFingerprint,
|
|
126
|
+
roleArgumentCompletions,
|
|
125
127
|
saveRoleConfig,
|
|
126
128
|
getRole,
|
|
127
129
|
formatRolesForDisplay,
|
|
@@ -145,6 +147,7 @@ import {
|
|
|
145
147
|
getProfile,
|
|
146
148
|
formatProfilesForDisplay,
|
|
147
149
|
describeProfile,
|
|
150
|
+
profileArgumentCompletions,
|
|
148
151
|
validateProfileName,
|
|
149
152
|
applyProfileOverlay,
|
|
150
153
|
missingProfileRoles,
|
|
@@ -1587,39 +1590,11 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
1587
1590
|
);
|
|
1588
1591
|
updateStatus(ctx as any);
|
|
1589
1592
|
},
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
description:
|
|
1596
|
-
"agent cannot git add/commit/push — you commit (or /commit)",
|
|
1597
|
-
},
|
|
1598
|
-
{
|
|
1599
|
-
value: "ask",
|
|
1600
|
-
label: "ask",
|
|
1601
|
-
description: "agent may commit; each git commit/push asks first",
|
|
1602
|
-
},
|
|
1603
|
-
{
|
|
1604
|
-
value: "allow",
|
|
1605
|
-
label: "allow",
|
|
1606
|
-
description:
|
|
1607
|
-
"agent may commit freely (per-step during plan execution)",
|
|
1608
|
-
},
|
|
1609
|
-
{
|
|
1610
|
-
value: "global",
|
|
1611
|
-
label: "global",
|
|
1612
|
-
description: "write to ~/.pi/agent/settings.json",
|
|
1613
|
-
},
|
|
1614
|
-
{
|
|
1615
|
-
value: "project",
|
|
1616
|
-
label: "project",
|
|
1617
|
-
description: "write to <cwd>/.pi/settings.json",
|
|
1618
|
-
},
|
|
1619
|
-
];
|
|
1620
|
-
const filtered = flags.filter((f) => f.value.startsWith(prefix));
|
|
1621
|
-
return filtered.length ? filtered : null;
|
|
1622
|
-
},
|
|
1593
|
+
// pi-tui full-argument contract (see commitPolicyArgumentCompletions):
|
|
1594
|
+
// modes first, then the write scope, with values that replace the whole
|
|
1595
|
+
// typed argument text ("ask project", never bare "project").
|
|
1596
|
+
getArgumentCompletions: (prefix: string) =>
|
|
1597
|
+
commitPolicyArgumentCompletions(prefix),
|
|
1623
1598
|
});
|
|
1624
1599
|
|
|
1625
1600
|
pi.registerCommand("review-prompt", {
|
|
@@ -3042,6 +3017,21 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
3042
3017
|
|
|
3043
3018
|
pi.registerCommand("role", {
|
|
3044
3019
|
description: "Configure one model + thinking per role — opens picker UI",
|
|
3020
|
+
// pi-tui full-argument contract (see roleArgumentCompletions): positional
|
|
3021
|
+
// set/use/remove completions whose values replace the whole typed
|
|
3022
|
+
// argument text ("set planner <provider/model>"), never bare tokens.
|
|
3023
|
+
getArgumentCompletions: (prefix: string) => {
|
|
3024
|
+
// No ctx here — use the live session cwd so project-scope roles.json is
|
|
3025
|
+
// honored, matching the handler's ensureRoleConfig(cwd) key.
|
|
3026
|
+
ensureRoleConfig((currentSessionCtx as any)?.cwd);
|
|
3027
|
+
const models = collectRoleCatalog(currentSessionCtx as any).all.map(
|
|
3028
|
+
(e) => formatModelRef({ provider: e.provider, id: e.id } as RoleModel),
|
|
3029
|
+
);
|
|
3030
|
+
return roleArgumentCompletions(prefix, {
|
|
3031
|
+
roleNames: roleConfig.roles.map((r) => r.name),
|
|
3032
|
+
models,
|
|
3033
|
+
});
|
|
3034
|
+
},
|
|
3045
3035
|
handler: async (args, ctx) => {
|
|
3046
3036
|
const cwd = (ctx as any)?.cwd as string | undefined;
|
|
3047
3037
|
ensureRoleConfig(cwd);
|
|
@@ -4553,90 +4543,31 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
4553
4543
|
return;
|
|
4554
4544
|
}
|
|
4555
4545
|
|
|
4556
|
-
// Bare profile name → show detail (mirrors /role <name>).
|
|
4546
|
+
// Bare profile name → show detail (mirrors /role <name>). The hint
|
|
4547
|
+
// matters: this branch is where the old completion bug dropped users
|
|
4548
|
+
// who meant `/profile use <name>`.
|
|
4557
4549
|
const named = getProfile(profilesConfig, sub);
|
|
4558
4550
|
if (named) {
|
|
4559
|
-
ctx.ui.notify(
|
|
4551
|
+
ctx.ui.notify(
|
|
4552
|
+
`${describeProfile(named)}\n\nActivate it with /profile use ${named.name}.`,
|
|
4553
|
+
"info",
|
|
4554
|
+
);
|
|
4560
4555
|
return;
|
|
4561
4556
|
}
|
|
4562
4557
|
ctx.ui.notify(PROFILE_USAGE, "info");
|
|
4563
4558
|
},
|
|
4559
|
+
// pi-tui contract: argument completions replace the ENTIRE typed argument
|
|
4560
|
+
// text with item.value (see profileArgumentCompletions / utils.joinArgument
|
|
4561
|
+
// Value). The old inline completer returned bare profile names, so
|
|
4562
|
+
// accepting a suggestion rewrote "/profile use X" → "/profile X" and the
|
|
4563
|
+
// command silently showed details instead of activating.
|
|
4564
4564
|
getArgumentCompletions: (prefix: string) => {
|
|
4565
|
-
|
|
4566
|
-
|
|
4567
|
-
|
|
4568
|
-
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
description: "activate/deactivate for this session",
|
|
4572
|
-
},
|
|
4573
|
-
{
|
|
4574
|
-
value: "create",
|
|
4575
|
-
label: "create <name>",
|
|
4576
|
-
description: "create a profile (seeded from current models)",
|
|
4577
|
-
},
|
|
4578
|
-
{
|
|
4579
|
-
value: "edit",
|
|
4580
|
-
label: "edit <name>",
|
|
4581
|
-
description: "open the tabbed model editor",
|
|
4582
|
-
},
|
|
4583
|
-
{
|
|
4584
|
-
value: "rename",
|
|
4585
|
-
label: "rename <old> <new>",
|
|
4586
|
-
description: "rename a profile",
|
|
4587
|
-
},
|
|
4588
|
-
{
|
|
4589
|
-
value: "delete",
|
|
4590
|
-
label: "delete <name>",
|
|
4591
|
-
description: "delete a profile",
|
|
4592
|
-
},
|
|
4593
|
-
{
|
|
4594
|
-
value: "default",
|
|
4595
|
-
label: "default [name|none] [global|project]",
|
|
4596
|
-
description: "default profile for new sessions",
|
|
4597
|
-
},
|
|
4598
|
-
{
|
|
4599
|
-
value: "show",
|
|
4600
|
-
label: "show <name>",
|
|
4601
|
-
description: "show one profile's models",
|
|
4602
|
-
},
|
|
4603
|
-
];
|
|
4604
|
-
const lower = String(prefix || "").toLowerCase();
|
|
4605
|
-
if (lower.includes(" ")) {
|
|
4606
|
-
// Completing an argument after a subcommand → profile names where relevant.
|
|
4607
|
-
const cmd = lower.trim().split(/\s+/)[0];
|
|
4608
|
-
const wantsNames =
|
|
4609
|
-
cmd === "use" ||
|
|
4610
|
-
cmd === "edit" ||
|
|
4611
|
-
cmd === "delete" ||
|
|
4612
|
-
cmd === "show" ||
|
|
4613
|
-
cmd === "default";
|
|
4614
|
-
if (!wantsNames) return null;
|
|
4615
|
-
const last = lower.trim().split(/\s+/).pop() ?? "";
|
|
4616
|
-
const names = profilesConfig.profiles
|
|
4617
|
-
.filter((p) => p.name.toLowerCase().startsWith(last))
|
|
4618
|
-
.map((p) => ({
|
|
4619
|
-
value: p.name,
|
|
4620
|
-
label: p.name,
|
|
4621
|
-
description: p.description || undefined,
|
|
4622
|
-
}));
|
|
4623
|
-
return cmd === "use" || cmd === "default"
|
|
4624
|
-
? [
|
|
4625
|
-
...names,
|
|
4626
|
-
{ value: "none", label: "none", description: "no profile" },
|
|
4627
|
-
]
|
|
4628
|
-
: names;
|
|
4629
|
-
}
|
|
4630
|
-
const matching = subs.filter((s) => s.value.startsWith(lower));
|
|
4631
|
-
if (matching.length > 0) return matching;
|
|
4632
|
-
const names = profilesConfig.profiles
|
|
4633
|
-
.filter((p) => p.name.toLowerCase().startsWith(lower))
|
|
4634
|
-
.map((p) => ({
|
|
4635
|
-
value: p.name,
|
|
4636
|
-
label: p.name,
|
|
4637
|
-
description: p.description || undefined,
|
|
4638
|
-
}));
|
|
4639
|
-
return names.length ? names : null;
|
|
4565
|
+
// The completion callback gets no ctx — use the live session cwd so
|
|
4566
|
+
// project-scope profiles (<cwd>/.pi/profiles.json) are included and the
|
|
4567
|
+
// profiles cache key matches the handler's (a cwd-less reload could
|
|
4568
|
+
// otherwise deactivate an active project-scope profile).
|
|
4569
|
+
ensureProfilesConfig((currentSessionCtx as any)?.cwd);
|
|
4570
|
+
return profileArgumentCompletions(prefix, profilesConfig.profiles);
|
|
4640
4571
|
},
|
|
4641
4572
|
});
|
|
4642
4573
|
|
package/package.json
CHANGED
package/profiles.ts
CHANGED
|
@@ -40,6 +40,8 @@ import {
|
|
|
40
40
|
roleIcon,
|
|
41
41
|
} from "./roles.ts";
|
|
42
42
|
import { COMMIT_CONFIG_DIR, WORKFLOW_SETTINGS_KEY } from "./commit-policy.ts";
|
|
43
|
+
import type { ArgumentCompletion } from "./utils.ts";
|
|
44
|
+
import { joinArgumentValue, suppressExactArgumentMatch } from "./utils.ts";
|
|
43
45
|
|
|
44
46
|
// ── Types ────────────────────────────────────────────────────────────
|
|
45
47
|
|
|
@@ -66,7 +68,7 @@ export const PROFILE_CONFIG_VERSION = 1;
|
|
|
66
68
|
export const MAX_PROFILE_NAME_LENGTH = 40;
|
|
67
69
|
|
|
68
70
|
/** Reserved words — they collide with `/profile use <name|none>` and `/role use` style keywords. */
|
|
69
|
-
export const RESERVED_PROFILE_NAMES: readonly string[] = ["none", "auto"];
|
|
71
|
+
export const RESERVED_PROFILE_NAMES: readonly string[] = ["none", "auto", "off"];
|
|
70
72
|
|
|
71
73
|
/** Fallback thinking level when a sanitized model omits an invalid one. */
|
|
72
74
|
const DEFAULT_THINKING: ThinkingLevel = "medium";
|
|
@@ -615,3 +617,208 @@ export function writeDefaultProfileSetting(
|
|
|
615
617
|
};
|
|
616
618
|
}
|
|
617
619
|
}
|
|
620
|
+
|
|
621
|
+
// ── Argument completions (/profile) ──────────────────────────────────
|
|
622
|
+
|
|
623
|
+
/** Subcommands that take a profile-name argument (position 2). */
|
|
624
|
+
const PROFILE_NAME_SUBS: ReadonlySet<string> = new Set([
|
|
625
|
+
"use",
|
|
626
|
+
"edit",
|
|
627
|
+
"show",
|
|
628
|
+
"delete",
|
|
629
|
+
"default",
|
|
630
|
+
"rename",
|
|
631
|
+
]);
|
|
632
|
+
|
|
633
|
+
/** Aliases the /profile handler accepts, mapped to their canonical subcommand. */
|
|
634
|
+
const PROFILE_CMD_ALIASES: Readonly<Record<string, string>> = {
|
|
635
|
+
activate: "use",
|
|
636
|
+
config: "edit",
|
|
637
|
+
remove: "delete",
|
|
638
|
+
rm: "delete",
|
|
639
|
+
mv: "rename",
|
|
640
|
+
new: "create",
|
|
641
|
+
add: "create",
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
/** Write-scope descriptions for `/profile default <name> <scope>` completions. */
|
|
645
|
+
const SCOPE_DESCRIPTIONS: Readonly<Record<string, string>> = {
|
|
646
|
+
global: "write to ~/.pi/agent/settings.json",
|
|
647
|
+
project: "write to <cwd>/.pi/settings.json",
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
interface ProfileSubMeta {
|
|
651
|
+
name: string;
|
|
652
|
+
/** Takes an argument → completion `value` carries a trailing space. */
|
|
653
|
+
takesArg: boolean;
|
|
654
|
+
label: string;
|
|
655
|
+
description: string;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const PROFILE_SUBS: readonly ProfileSubMeta[] = [
|
|
659
|
+
{
|
|
660
|
+
name: "list",
|
|
661
|
+
takesArg: false,
|
|
662
|
+
label: "list",
|
|
663
|
+
description: "show all profiles",
|
|
664
|
+
},
|
|
665
|
+
{
|
|
666
|
+
name: "use",
|
|
667
|
+
takesArg: true,
|
|
668
|
+
label: "use <name|none>",
|
|
669
|
+
description: "activate/deactivate for this session",
|
|
670
|
+
},
|
|
671
|
+
{
|
|
672
|
+
name: "create",
|
|
673
|
+
takesArg: true,
|
|
674
|
+
label: "create <name>",
|
|
675
|
+
description: "create a profile (seeded from current models)",
|
|
676
|
+
},
|
|
677
|
+
{
|
|
678
|
+
name: "edit",
|
|
679
|
+
takesArg: true,
|
|
680
|
+
label: "edit <name>",
|
|
681
|
+
description: "open the tabbed model editor",
|
|
682
|
+
},
|
|
683
|
+
{
|
|
684
|
+
name: "rename",
|
|
685
|
+
takesArg: true,
|
|
686
|
+
label: "rename <old> <new>",
|
|
687
|
+
description: "rename a profile",
|
|
688
|
+
},
|
|
689
|
+
{
|
|
690
|
+
name: "delete",
|
|
691
|
+
takesArg: true,
|
|
692
|
+
label: "delete <name>",
|
|
693
|
+
description: "delete a profile",
|
|
694
|
+
},
|
|
695
|
+
{
|
|
696
|
+
name: "default",
|
|
697
|
+
takesArg: true,
|
|
698
|
+
label: "default [name|none] [global|project]",
|
|
699
|
+
description: "default profile for new sessions",
|
|
700
|
+
},
|
|
701
|
+
{
|
|
702
|
+
name: "show",
|
|
703
|
+
takesArg: true,
|
|
704
|
+
label: "show <name>",
|
|
705
|
+
description: "show one profile's models",
|
|
706
|
+
},
|
|
707
|
+
];
|
|
708
|
+
|
|
709
|
+
/** Profile names matching a lowercased partial token, case-insensitively. */
|
|
710
|
+
function profileNameMatches(
|
|
711
|
+
profiles: readonly Profile[],
|
|
712
|
+
partial: string,
|
|
713
|
+
): Profile[] {
|
|
714
|
+
if (!partial) return [...profiles];
|
|
715
|
+
return profiles.filter((p) =>
|
|
716
|
+
p.name.toLowerCase().startsWith(partial.toLowerCase()),
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function profileCompletions(
|
|
721
|
+
profiles: readonly Profile[],
|
|
722
|
+
join: (token: string) => string,
|
|
723
|
+
): ArgumentCompletion[] {
|
|
724
|
+
return profiles.map((p) => ({
|
|
725
|
+
value: join(p.name),
|
|
726
|
+
label: p.name,
|
|
727
|
+
...(p.description ? { description: p.description } : {}),
|
|
728
|
+
}));
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Argument completions for `/profile` (pure; unit-tested in profiles.test.ts).
|
|
733
|
+
*
|
|
734
|
+
* `argumentPrefix` is the ENTIRE typed argument text after "/profile " (what
|
|
735
|
+
* pi's CombinedAutocompleteProvider passes). Per the pi-tui contract, every
|
|
736
|
+
* returned item's `value` is the complete replacement for that argument text
|
|
737
|
+
* (`joinArgumentValue`), never a bare token — accepting `"use Syn-De" →
|
|
738
|
+
* "Syn-…"` would otherwise drop the `use` subcommand (see the /mcp
|
|
739
|
+
* extension for the reference implementation).
|
|
740
|
+
*
|
|
741
|
+
* Returns null when there is nothing useful to offer (unknown subcommand,
|
|
742
|
+
* `rename <old> <new>` second arg, `create`, …) or when the typed token is
|
|
743
|
+
* already an exact, unique match (exact-match suppression → the dropdown
|
|
744
|
+
* closes and the first Enter submits the command).
|
|
745
|
+
*/
|
|
746
|
+
export function profileArgumentCompletions(
|
|
747
|
+
argumentPrefix: string,
|
|
748
|
+
profiles: readonly Profile[],
|
|
749
|
+
): ArgumentCompletion[] | null {
|
|
750
|
+
const raw = String(argumentPrefix ?? "");
|
|
751
|
+
const join = (token: string) => joinArgumentValue(raw, token);
|
|
752
|
+
|
|
753
|
+
// ── Subcommand position: no whitespace typed yet ────────────────────
|
|
754
|
+
if (!raw.trim() || !/\s/.test(raw)) {
|
|
755
|
+
const lower = raw.trim().toLowerCase();
|
|
756
|
+
const matching = PROFILE_SUBS.filter((s) =>
|
|
757
|
+
s.name.startsWith(lower),
|
|
758
|
+
);
|
|
759
|
+
if (matching.length > 0) {
|
|
760
|
+
return matching.map((s) => ({
|
|
761
|
+
value: s.takesArg ? `${s.name} ` : s.name,
|
|
762
|
+
label: s.label,
|
|
763
|
+
description: s.description,
|
|
764
|
+
}));
|
|
765
|
+
}
|
|
766
|
+
// Bare profile name → `/profile <name>` shows detail (mirrors /role).
|
|
767
|
+
const names = profileNameMatches(profiles, lower);
|
|
768
|
+
if (names.length === 0) return null;
|
|
769
|
+
return profileCompletions(names, join);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// ── Argument position ────────────────────────────────────────────────
|
|
773
|
+
const tokens = raw.trim().split(/\s+/).filter(Boolean);
|
|
774
|
+
const head = tokens[0] ?? "";
|
|
775
|
+
const canonical = PROFILE_CMD_ALIASES[head.toLowerCase()] ?? head.toLowerCase();
|
|
776
|
+
const endsWithWs = /\s$/.test(raw);
|
|
777
|
+
const committedCount = endsWithWs ? tokens.length : tokens.length - 1;
|
|
778
|
+
const partial = endsWithWs ? "" : (tokens[tokens.length - 1] ?? "");
|
|
779
|
+
|
|
780
|
+
let items: ArgumentCompletion[] | null = null;
|
|
781
|
+
|
|
782
|
+
if (canonical === "default") {
|
|
783
|
+
if (committedCount <= 1) {
|
|
784
|
+
// Second token: profile name | none.
|
|
785
|
+
const names = profileNameMatches(profiles, partial);
|
|
786
|
+
const none: ArgumentCompletion[] =
|
|
787
|
+
"none".startsWith(partial.toLowerCase())
|
|
788
|
+
? [{ value: join("none"), label: "none", description: "no profile" }]
|
|
789
|
+
: [];
|
|
790
|
+
items = [...profileCompletions(names, join), ...none];
|
|
791
|
+
} else {
|
|
792
|
+
// Third token: scope.
|
|
793
|
+
items = ["global", "project"].flatMap((s) =>
|
|
794
|
+
s.startsWith(partial.toLowerCase())
|
|
795
|
+
? [
|
|
796
|
+
{
|
|
797
|
+
value: join(s),
|
|
798
|
+
label: s,
|
|
799
|
+
description: SCOPE_DESCRIPTIONS[s],
|
|
800
|
+
},
|
|
801
|
+
]
|
|
802
|
+
: [],
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
} else if (canonical === "rename") {
|
|
806
|
+
// First arg: existing name; second arg (new name) is free text.
|
|
807
|
+
if (committedCount <= 1) {
|
|
808
|
+
items = profileCompletions(profileNameMatches(profiles, partial), join);
|
|
809
|
+
}
|
|
810
|
+
} else if (PROFILE_NAME_SUBS.has(canonical)) {
|
|
811
|
+
const names = profileNameMatches(profiles, partial);
|
|
812
|
+
const none: ArgumentCompletion[] =
|
|
813
|
+
canonical === "use" && "none".startsWith(partial.toLowerCase())
|
|
814
|
+
? [{ value: join("none"), label: "none", description: "no profile" }]
|
|
815
|
+
: [];
|
|
816
|
+
items = [...profileCompletions(names, join), ...none];
|
|
817
|
+
}
|
|
818
|
+
// create|new|add (new name is free text), list, unknown → items stays null.
|
|
819
|
+
|
|
820
|
+
if (!items || items.length === 0) return null;
|
|
821
|
+
const candidateTokens = items.map((i) => i.label);
|
|
822
|
+
if (suppressExactArgumentMatch(candidateTokens, partial)) return null;
|
|
823
|
+
return items;
|
|
824
|
+
}
|
package/roles.ts
CHANGED
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
import * as fs from "node:fs";
|
|
14
14
|
import * as path from "node:path";
|
|
15
15
|
import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import type { ArgumentCompletion } from "./utils.ts";
|
|
17
|
+
import {
|
|
18
|
+
joinArgumentValue,
|
|
19
|
+
suppressExactArgumentMatch,
|
|
20
|
+
} from "./utils.ts";
|
|
16
21
|
|
|
17
22
|
// ── Types ────────────────────────────────────────────────────────────
|
|
18
23
|
|
|
@@ -430,3 +435,140 @@ export function formatRolesForDisplay(config: RoleConfig): string {
|
|
|
430
435
|
);
|
|
431
436
|
return lines.join("\n");
|
|
432
437
|
}
|
|
438
|
+
|
|
439
|
+
// ── Argument completions (/role) ─────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
interface RoleSubMeta {
|
|
442
|
+
name: string;
|
|
443
|
+
/** Takes an argument → completion `value` carries a trailing space. */
|
|
444
|
+
takesArg: boolean;
|
|
445
|
+
label: string;
|
|
446
|
+
description: string;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const ROLE_SUBS: readonly RoleSubMeta[] = [
|
|
450
|
+
{ name: "list", takesArg: false, label: "list", description: "show all roles" },
|
|
451
|
+
{
|
|
452
|
+
name: "show",
|
|
453
|
+
takesArg: false,
|
|
454
|
+
label: "show",
|
|
455
|
+
description: "show all roles (alias of list)",
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
name: "config",
|
|
459
|
+
takesArg: true,
|
|
460
|
+
label: "config",
|
|
461
|
+
description: "open the role config screen (picker)",
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
name: "set",
|
|
465
|
+
takesArg: true,
|
|
466
|
+
label: "set <role> <provider/model> [thinking]",
|
|
467
|
+
description: "configure one role's model",
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
name: "add",
|
|
471
|
+
takesArg: true,
|
|
472
|
+
label: "add <name> [provider/model] [thinking]",
|
|
473
|
+
description: "add a custom role",
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
name: "remove",
|
|
477
|
+
takesArg: true,
|
|
478
|
+
label: "remove <name>",
|
|
479
|
+
description: "remove a custom role",
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
name: "reset",
|
|
483
|
+
takesArg: false,
|
|
484
|
+
label: "reset",
|
|
485
|
+
description: "reset roles to defaults",
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
name: "use",
|
|
489
|
+
takesArg: true,
|
|
490
|
+
label: "use <role|auto>",
|
|
491
|
+
description: "activate one role for the whole workflow",
|
|
492
|
+
},
|
|
493
|
+
];
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Argument completions for `/role` (pure; unit-tested in roles.test.ts).
|
|
497
|
+
*
|
|
498
|
+
* Follows the pi-tui full-argument contract (see `joinArgumentValue` and the
|
|
499
|
+
* /mcp extension): every returned `value` is the complete replacement for the
|
|
500
|
+
* typed argument text — `set planner opencode-go/muse-…`, never a bare token.
|
|
501
|
+
* Positional rules mirror the /role handler:
|
|
502
|
+
* - `set` arg1 = existing role names, arg2 = model refs (`provider/id`,
|
|
503
|
+
* null when `opts.models` is empty), arg3 = thinking levels;
|
|
504
|
+
* - `use` = role names + `auto`;
|
|
505
|
+
* - `remove` = role names;
|
|
506
|
+
* - `add` arg1 is a NEW name → no completions.
|
|
507
|
+
* Returns null when nothing useful can be offered, including when the typed
|
|
508
|
+
* token is already an exact unique match (exact-match suppression — the
|
|
509
|
+
* dropdown closes so the first Enter submits).
|
|
510
|
+
*/
|
|
511
|
+
export function roleArgumentCompletions(
|
|
512
|
+
argumentPrefix: string,
|
|
513
|
+
opts: { roleNames: readonly string[]; models?: readonly string[] },
|
|
514
|
+
): ArgumentCompletion[] | null {
|
|
515
|
+
const raw = String(argumentPrefix ?? "");
|
|
516
|
+
const join = (token: string) => joinArgumentValue(raw, token);
|
|
517
|
+
|
|
518
|
+
// ── Subcommand position: no whitespace typed yet ────────────────────
|
|
519
|
+
if (!raw.trim() || !/\s/.test(raw)) {
|
|
520
|
+
const lower = raw.trim().toLowerCase();
|
|
521
|
+
const matching = ROLE_SUBS.filter((s) => s.name.startsWith(lower));
|
|
522
|
+
if (matching.length === 0) return null;
|
|
523
|
+
return matching.map((s) => ({
|
|
524
|
+
value: s.takesArg ? `${s.name} ` : s.name,
|
|
525
|
+
label: s.label,
|
|
526
|
+
description: s.description,
|
|
527
|
+
}));
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ── Argument position ────────────────────────────────────────────────
|
|
531
|
+
const tokens = raw.trim().split(/\s+/).filter(Boolean);
|
|
532
|
+
const cmd = (tokens[0] ?? "").toLowerCase();
|
|
533
|
+
const endsWithWs = /\s$/.test(raw);
|
|
534
|
+
const committedCount = endsWithWs ? tokens.length : tokens.length - 1;
|
|
535
|
+
const partial = endsWithWs ? "" : (tokens[tokens.length - 1] ?? "");
|
|
536
|
+
const matchToken = (t: string) => t.toLowerCase().startsWith(partial.toLowerCase());
|
|
537
|
+
|
|
538
|
+
const roleNameItems = (): ArgumentCompletion[] =>
|
|
539
|
+
opts.roleNames.flatMap((n) =>
|
|
540
|
+
matchToken(n) ? [{ value: join(n), label: n }] : [],
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
let items: ArgumentCompletion[] | null = null;
|
|
544
|
+
|
|
545
|
+
if (cmd === "set") {
|
|
546
|
+
if (committedCount <= 1) {
|
|
547
|
+
items = roleNameItems();
|
|
548
|
+
} else if (committedCount === 2) {
|
|
549
|
+
items = (opts.models ?? []).flatMap((m) =>
|
|
550
|
+
matchToken(m) ? [{ value: join(m), label: m }] : [],
|
|
551
|
+
);
|
|
552
|
+
} else {
|
|
553
|
+
items = VALID_THINKING_LEVELS.flatMap((t) =>
|
|
554
|
+
matchToken(t) ? [{ value: join(t), label: t }] : [],
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
} else if (cmd === "use") {
|
|
558
|
+
items = [
|
|
559
|
+
...roleNameItems(),
|
|
560
|
+
...("auto".startsWith(partial.toLowerCase())
|
|
561
|
+
? [{ value: join("auto"), label: "auto" }]
|
|
562
|
+
: []),
|
|
563
|
+
];
|
|
564
|
+
} else if (cmd === "remove") {
|
|
565
|
+
items = roleNameItems();
|
|
566
|
+
}
|
|
567
|
+
// add (new name is free text), config, list/show, reset, unknown → null.
|
|
568
|
+
|
|
569
|
+
if (!items || items.length === 0) return null;
|
|
570
|
+
if (suppressExactArgumentMatch(items.map((i) => i.label), partial)) {
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
return items;
|
|
574
|
+
}
|
package/utils.ts
CHANGED
|
@@ -1583,3 +1583,63 @@ export function renderReviewContextBlock(
|
|
|
1583
1583
|
}
|
|
1584
1584
|
return out;
|
|
1585
1585
|
}
|
|
1586
|
+
|
|
1587
|
+
// ── Slash-command argument completions (pi-tui contract) ─────────────
|
|
1588
|
+
//
|
|
1589
|
+
// `CombinedAutocompleteProvider.getSuggestions` (pi-tui dist/autocomplete.js)
|
|
1590
|
+
// passes `getArgumentCompletions(argumentText)` the ENTIRE typed argument
|
|
1591
|
+
// text after the command name and, on accept, `applyCompletion` replaces
|
|
1592
|
+
// that whole argument text with `item.value` (the slash-argument branch:
|
|
1593
|
+
// `newLine = beforePrefix + item.value`). So an argument-position item's
|
|
1594
|
+
// `value` must be the complete replacement — e.g. `"use <name>"`, never the
|
|
1595
|
+
// bare token. Pi's own /mcp extension follows this contract
|
|
1596
|
+
// (`value: `${action} ${name}``). `joinArgumentValue` is the single helper
|
|
1597
|
+
// that enforces it: it keeps every committed token and appends (or replaces
|
|
1598
|
+
// the last partial token with) `token`.
|
|
1599
|
+
|
|
1600
|
+
/** One slash-command argument completion item (pi-tui `AutocompleteItem` shape). */
|
|
1601
|
+
export interface ArgumentCompletion {
|
|
1602
|
+
value: string;
|
|
1603
|
+
label: string;
|
|
1604
|
+
description?: string;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
/**
|
|
1608
|
+
* Build the full-argument replacement `value` for a completion token.
|
|
1609
|
+
*
|
|
1610
|
+
* `prefix` is the typed argument text so far. Tokens separated by whitespace
|
|
1611
|
+
* are "committed"; if `prefix` ends with whitespace the user is starting a
|
|
1612
|
+
* fresh token, so `token` is appended — otherwise it replaces the trailing
|
|
1613
|
+
* partial token. Pure; never throws.
|
|
1614
|
+
*
|
|
1615
|
+
* joinArgumentValue("use Syn-De", "Syn-Deepseek-v4.1-Flash-Planner")
|
|
1616
|
+
* → "use Syn-Deepseek-v4.1-Flash-Planner"
|
|
1617
|
+
* joinArgumentValue("use ", "none") → "use none"
|
|
1618
|
+
* joinArgumentValue("", "list") → "list"
|
|
1619
|
+
*/
|
|
1620
|
+
export function joinArgumentValue(prefix: string, token: string): string {
|
|
1621
|
+
const raw = String(prefix ?? "");
|
|
1622
|
+
const endsWithWs = /\s$/.test(raw);
|
|
1623
|
+
const parts = raw.trim().split(/\s+/).filter(Boolean);
|
|
1624
|
+
const committed = endsWithWs ? parts : parts.slice(0, -1);
|
|
1625
|
+
return [...committed, token].join(" ");
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
/**
|
|
1629
|
+
* Exact-match suppression for slash-command argument completions: when the
|
|
1630
|
+
* token being typed (`partial`, "" while the prefix ends with whitespace)
|
|
1631
|
+
* already equals the ONLY remaining candidate, return true so the completer
|
|
1632
|
+
* yields null — pi's editor then closes the dropdown and the FIRST Enter
|
|
1633
|
+
* submits instead of being consumed by the completion-accept handler
|
|
1634
|
+
* (editor.js `tui.select.confirm`). Pure.
|
|
1635
|
+
*/
|
|
1636
|
+
export function suppressExactArgumentMatch(
|
|
1637
|
+
candidates: readonly string[],
|
|
1638
|
+
partial: string,
|
|
1639
|
+
): boolean {
|
|
1640
|
+
if (!partial) return false;
|
|
1641
|
+
return (
|
|
1642
|
+
candidates.length === 1 &&
|
|
1643
|
+
candidates[0].toLowerCase() === partial.toLowerCase()
|
|
1644
|
+
);
|
|
1645
|
+
}
|