@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 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
- getArgumentCompletions: (prefix: string) => {
1591
- const flags = [
1592
- {
1593
- value: "never",
1594
- label: "never",
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(describeProfile(named), "info");
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
- ensureProfilesConfig();
4566
- const subs = [
4567
- { value: "list", label: "list", description: "show all profiles" },
4568
- {
4569
- value: "use",
4570
- label: "use <name|none>",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dieulc/workflow",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "keywords": ["pi-package", "workflow", "plan-mode", "review-mode", "questionnaire", "rewind"],
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
+ }