@dieulc/workflow 0.5.1 → 0.6.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 +100 -3
- package/commit-policy.ts +103 -0
- package/edit-policy.ts +955 -0
- package/index.ts +429 -115
- package/package.json +3 -2
- package/permissions.ts +25 -5
- package/profiles.ts +208 -1
- package/roles.ts +142 -0
- package/utils.ts +60 -0
package/permissions.ts
CHANGED
|
@@ -424,8 +424,14 @@ const DESTRUCTIVE_PATTERNS: RegExp[] = [
|
|
|
424
424
|
/(^|\s)(sh|bash|dash|zsh|fish|ksh|csh|tcsh|pwsh|powershell|cmd|cscript|wscript|perl|ruby)(\.exe)?(\s|$)/i,
|
|
425
425
|
];
|
|
426
426
|
|
|
427
|
-
/**
|
|
428
|
-
|
|
427
|
+
/**
|
|
428
|
+
* Writing/formatting flags that must never reach a verify-class command.
|
|
429
|
+
*
|
|
430
|
+
* Exported for the write policy's command classification (edit-policy.ts):
|
|
431
|
+
* a verify-class command that carries one of these still rewrites files
|
|
432
|
+
* (`eslint --fix`), so the write gate must treat it as a write.
|
|
433
|
+
*/
|
|
434
|
+
export const DENIED_FLAG_RE =
|
|
429
435
|
/(^|\s)(--fix|--fix-dry-run|--write|--update|--update-snapshots?|--updateSnapshot|--test-update-snapshots|--watch|--watchAll|--inspect|--inspect-brk|--in-place|--output)(\s|$|=)/i;
|
|
430
436
|
|
|
431
437
|
/**
|
|
@@ -547,6 +553,22 @@ function packageScriptArg(segment: string): string | undefined {
|
|
|
547
553
|
const NULL_DEVICE_REDIRECT_RE =
|
|
548
554
|
/(^|[\s;&|()])(?:[12]|&)?>>?\s*\/dev\/null(?=$|[\s;&|)])/g;
|
|
549
555
|
|
|
556
|
+
/**
|
|
557
|
+
* Strip harmless redirection tokens from a command: fd duplications
|
|
558
|
+
* (`2>&1` / `1>&2`) and null-device targets (`> /dev/null`). Neither writes
|
|
559
|
+
* a file the user can see, so both are removed before the command is
|
|
560
|
+
* classified by its first word — otherwise `2>/dev/null ls` would look like
|
|
561
|
+
* an unknown binary and be treated as a write.
|
|
562
|
+
*
|
|
563
|
+
* Exported for the write policy's command classification (edit-policy.ts).
|
|
564
|
+
*/
|
|
565
|
+
export function stripHarmlessRedirects(command: unknown): string {
|
|
566
|
+
const raw = typeof command === "string" ? command : "";
|
|
567
|
+
return raw
|
|
568
|
+
.replace(/\d*>&\d+/g, " ")
|
|
569
|
+
.replace(NULL_DEVICE_REDIRECT_RE, "$1");
|
|
570
|
+
}
|
|
571
|
+
|
|
550
572
|
/**
|
|
551
573
|
* Why a command's syntax cannot be classified (`null` = the syntax is fine).
|
|
552
574
|
* Mirrors the order the gate has always used, so a refusal can name the first
|
|
@@ -556,9 +578,7 @@ export function forbiddenSyntaxReason(command: unknown): string | null {
|
|
|
556
578
|
const raw = typeof command === "string" ? command : "";
|
|
557
579
|
// `2>&1` / `1>&2` are fd duplications, not file writes; `/dev/null` targets
|
|
558
580
|
// are a bit bucket, not a file.
|
|
559
|
-
const scrubbed = raw
|
|
560
|
-
.replace(/\d*>&\d+/g, " ")
|
|
561
|
-
.replace(NULL_DEVICE_REDIRECT_RE, "$1");
|
|
581
|
+
const scrubbed = stripHarmlessRedirects(raw);
|
|
562
582
|
if (scrubbed.replace(/&&/g, " ").includes("&"))
|
|
563
583
|
return "a background `&` (only `&&` chains are allowed)";
|
|
564
584
|
if (raw.includes("$(")) return "command substitution `$(...)`";
|
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
|
+
}
|