pi-umbra 0.3.1 → 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 +9 -7
- package/node_modules/pi-umbra-copy-chat/README.md +4 -7
- package/node_modules/pi-umbra-copy-chat/extensions/umbra-copy-chat.ts +16 -54
- package/node_modules/pi-umbra-copy-chat/package.json +1 -1
- package/node_modules/pi-umbra-help/README.md +1 -1
- package/node_modules/pi-umbra-help/checks/umbra-help.check.ts +11 -7
- package/node_modules/pi-umbra-help/extensions/umbra-help.ts +33 -6
- package/node_modules/pi-umbra-help/package.json +1 -1
- package/node_modules/pi-umbra-inputbar/README.md +1 -1
- package/node_modules/pi-umbra-inputbar/extensions/umbra-inputbar.ts +1 -1
- package/node_modules/pi-umbra-inputbar/package.json +2 -2
- package/node_modules/pi-umbra-shimmer/package.json +1 -1
- package/node_modules/pi-umbra-shimmer/patch.mjs +23 -21
- package/node_modules/pi-umbra-subagents/README.md +19 -4
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar/bar-line.ts +9 -2
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar.check.ts +10 -1
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/index.ts +50 -5
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/spec.ts +24 -14
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/store.check.ts +119 -4
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/store.ts +138 -58
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/worktree.ts +156 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/state.ts +19 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/fan/SKILL.md +25 -4
- package/node_modules/pi-umbra-subagents/package.json +2 -2
- package/node_modules/pi-umbra-theme/checks/umbra-background.check.ts +7 -1
- package/node_modules/pi-umbra-theme/extensions/umbra-background.ts +2 -1
- package/node_modules/pi-umbra-theme/package.json +3 -3
- package/package.json +8 -14
- package/node_modules/pi-umbra-rename/LICENSE +0 -21
- package/node_modules/pi-umbra-rename/README.md +0 -18
- package/node_modules/pi-umbra-rename/checks/umbra-rename.check.ts +0 -55
- package/node_modules/pi-umbra-rename/extensions/umbra-rename.ts +0 -22
- package/node_modules/pi-umbra-rename/package.json +0 -36
- package/node_modules/pi-umbra-skill-matcher/LICENSE +0 -21
- package/node_modules/pi-umbra-skill-matcher/README.md +0 -45
- package/node_modules/pi-umbra-skill-matcher/checks/umbra-skill-matcher.check.ts +0 -94
- package/node_modules/pi-umbra-skill-matcher/extensions/umbra-skill-matcher.ts +0 -124
- package/node_modules/pi-umbra-skill-matcher/package.json +0 -37
- package/node_modules/pi-umbra-skill-matcher/patch.mjs +0 -129
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
// Runs skill-fuzzy against pi's REAL built-in provider, so a change in pi's prefix/value
|
|
2
|
-
// contract fails here instead of silently in the TUI.
|
|
3
|
-
// bun run umbra-skill-matcher.check.ts
|
|
4
|
-
import assert from "node:assert";
|
|
5
|
-
import { CombinedAutocompleteProvider } from "@earendil-works/pi-tui";
|
|
6
|
-
import skillFuzzy, { midPromptWord } from "../extensions/umbra-skill-matcher.ts";
|
|
7
|
-
|
|
8
|
-
const COMMANDS = [
|
|
9
|
-
{ name: "huh", source: "skill", description: "Restate the last message plainly." },
|
|
10
|
-
{ name: "hula-hola", source: "skill", description: "dance" },
|
|
11
|
-
{ name: "what-the-hex", source: "skill", description: "hex" },
|
|
12
|
-
{ name: "picasso", source: "skill", description: "frontend" },
|
|
13
|
-
{ name: "call-council", source: "skill", description: "council skill" },
|
|
14
|
-
{ name: "call-council", source: "extension", description: "council extension" },
|
|
15
|
-
{ name: "help", source: "builtin", description: "help" },
|
|
16
|
-
];
|
|
17
|
-
|
|
18
|
-
let wrap: (current: unknown) => any;
|
|
19
|
-
const pi = {
|
|
20
|
-
getCommands: () => COMMANDS,
|
|
21
|
-
on: (_event: string, handler: (e: unknown, ctx: unknown) => void) =>
|
|
22
|
-
handler({}, { ui: { addAutocompleteProvider: (factory: typeof wrap) => (wrap = factory) } }),
|
|
23
|
-
};
|
|
24
|
-
skillFuzzy(pi as never);
|
|
25
|
-
|
|
26
|
-
const builtin = new CombinedAutocompleteProvider(COMMANDS as never, process.cwd());
|
|
27
|
-
const provider = wrap!(builtin);
|
|
28
|
-
const labels = async (typed: string) => {
|
|
29
|
-
const got = await provider.getSuggestions([typed], 0, typed.length, { signal: AbortSignal.timeout(5000) });
|
|
30
|
-
return (got?.items ?? []).map((item: { label: string }) => item.label);
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
// The user's three examples: a shorter query lists more skills, all in the runnable form.
|
|
34
|
-
// pi's matcher is subsequence-based, not contains, so "/huh" also reaches "hula-hola" (h-u-h).
|
|
35
|
-
// That is pi's own ranking to own; all this checks is that the exact match leads.
|
|
36
|
-
assert.equal((await labels("/huh"))[0], "skill:huh");
|
|
37
|
-
assert.ok((await labels("/hu")).includes("skill:huh"), "/hu must offer skill:huh");
|
|
38
|
-
assert.ok((await labels("/hu")).includes("skill:hula-hola"), "/hu must offer skill:hula-hola");
|
|
39
|
-
assert.ok((await labels("/h")).includes("skill:what-the-hex"), "/h must reach a mid-word match");
|
|
40
|
-
|
|
41
|
-
// A skill is never offered under the bare name that does not run.
|
|
42
|
-
assert.ok(!(await labels("/pic")).includes("picasso"), "bare picasso must be gone");
|
|
43
|
-
assert.deepEqual(await labels("/pic"), ["skill:picasso"]);
|
|
44
|
-
|
|
45
|
-
// "skill:" typed by hand still finds it, which plain fuzzy on the bare name cannot.
|
|
46
|
-
assert.ok((await labels("/skill:pic")).includes("skill:picasso"), "explicit prefix must still match");
|
|
47
|
-
|
|
48
|
-
// call-council belongs to an extension too, so its bare form stays runnable and untouched.
|
|
49
|
-
assert.ok((await labels("/call")).includes("call-council"), "extension command must survive");
|
|
50
|
-
|
|
51
|
-
// Browsing the whole list with a bare "/" must show the runnable form too, not just a search.
|
|
52
|
-
const all = await labels("/");
|
|
53
|
-
assert.ok(all.includes("skill:huh"), "bare / must list skills in runnable form");
|
|
54
|
-
assert.ok(!all.includes("huh"), "bare / must not leak the name that does not run");
|
|
55
|
-
assert.ok(all.includes("help"), "bare / must keep non-skill commands");
|
|
56
|
-
|
|
57
|
-
// Selecting the row writes exactly "/skill:huh " — one slash, one trailing space.
|
|
58
|
-
const item = { value: "skill:huh", label: "skill:huh" };
|
|
59
|
-
const applied = provider.applyCompletion(["/huh"], 0, 4, item, "/huh");
|
|
60
|
-
assert.equal(applied.lines[0], "/skill:huh ");
|
|
61
|
-
assert.equal(applied.cursorCol, 11);
|
|
62
|
-
|
|
63
|
-
console.log("umbra-skill-matcher: all checks passed");
|
|
64
|
-
|
|
65
|
-
// --- mid-sentence ---------------------------------------------------------------------------
|
|
66
|
-
// The token test, first on its own. These are the shapes the repatch entries also decide with,
|
|
67
|
-
// so a change here without a change there means the menu opens on something it cannot fill.
|
|
68
|
-
assert.equal(midPromptWord("run this /pic"), "pic", "a token after real text is ours");
|
|
69
|
-
assert.equal(midPromptWord("run this /"), "", "an empty token is ours - that is the keystroke");
|
|
70
|
-
assert.equal(midPromptWord("/pic"), null, "the line-start form belongs to the built-in provider");
|
|
71
|
-
assert.equal(midPromptWord(" /pic"), null, "an indented line start is still a line start");
|
|
72
|
-
assert.equal(midPromptWord("open src/pic"), null, "a path is not a command");
|
|
73
|
-
assert.equal(midPromptWord("open ~/pic"), null, "nor is a home path");
|
|
74
|
-
assert.equal(midPromptWord("run /pic then"), null, "the token must end at the cursor");
|
|
75
|
-
|
|
76
|
-
// Mid-sentence rows carry the bare skill name, because nothing dispatches them: pi runs a command
|
|
77
|
-
// only when the message starts with a slash. The name is what pi's own <available_skills> block
|
|
78
|
-
// lists, so it is the form the model can act on.
|
|
79
|
-
const mid = async (typed: string) => {
|
|
80
|
-
const got = await provider.getSuggestions([typed], 0, typed.length, { signal: AbortSignal.timeout(5000) });
|
|
81
|
-
return (got?.items ?? []).map((item: { value: string }) => item.value);
|
|
82
|
-
};
|
|
83
|
-
assert.deepEqual(await mid("use the /pic"), ["/picasso"]);
|
|
84
|
-
assert.ok(!(await mid("use the /pic")).includes("/skill:picasso"), "no dispatcher prefix mid-sentence");
|
|
85
|
-
assert.ok((await mid("use the /call")).includes("/call-council"), "skills only, once");
|
|
86
|
-
assert.equal((await mid("use the /call")).length, 1, "the extension twin must not double the row");
|
|
87
|
-
assert.equal((await mid("use the /zzz")).length, 0, "no match means no menu");
|
|
88
|
-
|
|
89
|
-
// Selecting a mid-sentence row keeps the sentence and writes one slash.
|
|
90
|
-
const midItem = { value: "/picasso", label: "picasso" };
|
|
91
|
-
const midApplied = provider.applyCompletion(["use the /pic"], 0, 12, midItem, "/pic");
|
|
92
|
-
assert.equal(midApplied.lines[0], "use the /picasso");
|
|
93
|
-
|
|
94
|
-
console.log("umbra-skill-matcher: mid-sentence checks passed");
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
// pi registers a skill in the completion list under its bare name ("huh"), but only
|
|
2
|
-
// "/skill:huh" actually runs one. So the built-in dropdown offers "/huh", the user presses
|
|
3
|
-
// enter, and pi answers "Unknown command". The matching itself is already fuzzy; the name it
|
|
4
|
-
// matches against is what is wrong.
|
|
5
|
-
//
|
|
6
|
-
// This wraps the built-in provider and rewrites every skill row to the form that runs. It also
|
|
7
|
-
// re-runs the match against the prefixed name, so typing "/skill:hu" still finds "skill:huh"
|
|
8
|
-
// after the built-in fuzzy pass has given up on it.
|
|
9
|
-
//
|
|
10
|
-
// It also fills the mid-sentence slash menu that two repatch entries open. pi's editor gates the
|
|
11
|
-
// menu on the line starting with "/", so a "/name" written inside a sentence never reached a
|
|
12
|
-
// provider at all; the patches lift that gate and the branch below decides what appears.
|
|
13
|
-
//
|
|
14
|
-
// Contract taken from pi-tui's CombinedAutocompleteProvider: `prefix` is the text before the
|
|
15
|
-
// cursor INCLUDING the leading slash, and `item.value` is the bare command name — applyCompletion
|
|
16
|
-
// writes `${before}/${value} ` itself, so the value carries neither slash nor trailing space.
|
|
17
|
-
// That holds only while the token starts the line. Mid-sentence the same method falls through to
|
|
18
|
-
// `${before}${value}`, which is why those rows carry their own slash.
|
|
19
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
20
|
-
|
|
21
|
-
const SKILL_PREFIX = "skill:";
|
|
22
|
-
const MAX_ROWS = 12;
|
|
23
|
-
|
|
24
|
-
type Item = { value: string; label: string; description?: string };
|
|
25
|
-
type Suggestions = { items: Item[]; prefix: string } | null;
|
|
26
|
-
|
|
27
|
-
// Only complete while the command word is the whole line: "/pic" completes, "/skill:picasso go"
|
|
28
|
-
// does not, because there the user is writing the argument.
|
|
29
|
-
const commandWord = (before: string): string | null => {
|
|
30
|
-
const match = /^\/(\S*)$/.exec(before);
|
|
31
|
-
return match ? match[1] : null;
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
// The same token test the two repatch entries use to open the slash menu mid-sentence, so the
|
|
35
|
-
// menu and its contents agree on what counts: last token on the line, no space in it, and real
|
|
36
|
-
// text before it that ends in whitespace. "src/foo" and an indented " /foo" both fail, which
|
|
37
|
-
// leaves paths and line-start commands to the built-in provider.
|
|
38
|
-
export const midPromptWord = (before: string): string | null => {
|
|
39
|
-
const slash = before.lastIndexOf("/");
|
|
40
|
-
if (slash < 1 || before.slice(slash + 1).includes(" ")) return null;
|
|
41
|
-
const head = before.slice(0, slash);
|
|
42
|
-
return head.trim() === "" || head === head.trimEnd() ? null : before.slice(slash + 1);
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
export default function skillFuzzy(pi: ExtensionAPI) {
|
|
46
|
-
pi.on("session_start", (_event, ctx: ExtensionContext) => {
|
|
47
|
-
ctx.ui.addAutocompleteProvider((current) => ({
|
|
48
|
-
triggerCharacters: ["/"],
|
|
49
|
-
|
|
50
|
-
async getSuggestions(lines: string[], line: number, col: number, options: never) {
|
|
51
|
-
const before = (lines[line] ?? "").slice(0, col);
|
|
52
|
-
|
|
53
|
-
// pi dispatches a command only when the whole message starts with a slash, so a
|
|
54
|
-
// "/name" written mid-sentence is never run - it is text the model reads. pi's
|
|
55
|
-
// system prompt already lists every skill and tells the model to open the
|
|
56
|
-
// matching SKILL.md, so the bare name is what belongs here. The "skill:" form
|
|
57
|
-
// below exists for the dispatcher, and the dispatcher never sees this one.
|
|
58
|
-
const midWord = midPromptWord(before);
|
|
59
|
-
if (midWord !== null) {
|
|
60
|
-
const query = midWord.toLowerCase();
|
|
61
|
-
const items = pi
|
|
62
|
-
.getCommands()
|
|
63
|
-
.filter((command) => command.source === "skill" && command.name.toLowerCase().includes(query))
|
|
64
|
-
.slice(0, MAX_ROWS)
|
|
65
|
-
.map((command) => ({
|
|
66
|
-
// applyCompletion writes `${before}${value}` for a token that does not
|
|
67
|
-
// start the line, so the value carries its own slash.
|
|
68
|
-
value: `/${command.name}`,
|
|
69
|
-
label: command.name,
|
|
70
|
-
description: command.description ?? "skill",
|
|
71
|
-
}));
|
|
72
|
-
return items.length === 0 ? null : { items, prefix: `/${midWord}` };
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const base = (await current.getSuggestions(lines, line, col, options)) as Suggestions;
|
|
76
|
-
const typed = commandWord(before);
|
|
77
|
-
if (typed === null) return base;
|
|
78
|
-
|
|
79
|
-
const commands = pi.getCommands();
|
|
80
|
-
// "call-council" is both a skill and an extension command, and there the bare
|
|
81
|
-
// "/call-council" is the real one. Only touch a name nothing else claims.
|
|
82
|
-
const taken = new Set(
|
|
83
|
-
commands.filter((command) => command.source !== "skill").map((command) => command.name),
|
|
84
|
-
);
|
|
85
|
-
const skills = commands.filter(
|
|
86
|
-
(command) => command.source === "skill" && !taken.has(command.name),
|
|
87
|
-
);
|
|
88
|
-
const byName = new Map(skills.map((command) => [command.name, command]));
|
|
89
|
-
|
|
90
|
-
const asSkill = (name: string, description?: string): Item => ({
|
|
91
|
-
value: `${SKILL_PREFIX}${name}`,
|
|
92
|
-
label: `${SKILL_PREFIX}${name}`,
|
|
93
|
-
description: description ?? byName.get(name)?.description ?? "skill",
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
const fixed = (base?.items ?? []).map((item) =>
|
|
97
|
-
byName.has(item.value) ? asSkill(item.value, item.description) : item,
|
|
98
|
-
);
|
|
99
|
-
|
|
100
|
-
const query = typed.toLowerCase().replace(/^skill:/, "");
|
|
101
|
-
const shown = new Set(fixed.map((item) => item.value));
|
|
102
|
-
const extra = query
|
|
103
|
-
? skills
|
|
104
|
-
.filter((command) => command.name.toLowerCase().includes(query))
|
|
105
|
-
.map((command) => asSkill(command.name))
|
|
106
|
-
.filter((item) => !shown.has(item.value))
|
|
107
|
-
.slice(0, MAX_ROWS)
|
|
108
|
-
: [];
|
|
109
|
-
|
|
110
|
-
const items = [...fixed, ...extra];
|
|
111
|
-
if (items.length === 0) return null;
|
|
112
|
-
return { items, prefix: base?.prefix ?? `/${typed}` };
|
|
113
|
-
},
|
|
114
|
-
|
|
115
|
-
applyCompletion(lines: string[], line: number, col: number, item: Item, prefix: string) {
|
|
116
|
-
return current.applyCompletion(lines, line, col, item, prefix);
|
|
117
|
-
},
|
|
118
|
-
|
|
119
|
-
shouldTriggerFileCompletion(lines: string[], line: number, col: number) {
|
|
120
|
-
return current.shouldTriggerFileCompletion?.(lines, line, col) ?? true;
|
|
121
|
-
},
|
|
122
|
-
}));
|
|
123
|
-
});
|
|
124
|
-
}
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "pi-umbra-skill-matcher",
|
|
3
|
-
"version": "0.1.4",
|
|
4
|
-
"description": "Type /huh and reach skill:huh. pi lists every skill under its bare name, which is not the name that dispatches.",
|
|
5
|
-
"keywords": [
|
|
6
|
-
"pi-package",
|
|
7
|
-
"pi-extension",
|
|
8
|
-
"autocomplete",
|
|
9
|
-
"skills"
|
|
10
|
-
],
|
|
11
|
-
"author": "grkn",
|
|
12
|
-
"license": "MIT",
|
|
13
|
-
"repository": {
|
|
14
|
-
"type": "git",
|
|
15
|
-
"url": "git+https://github.com/grknbyk/pi-umbra.git",
|
|
16
|
-
"directory": "pi-umbra-skill-matcher"
|
|
17
|
-
},
|
|
18
|
-
"homepage": "https://github.com/grknbyk/pi-umbra/tree/main/pi-umbra-skill-matcher#readme",
|
|
19
|
-
"bugs": "https://github.com/grknbyk/pi-umbra/issues",
|
|
20
|
-
"type": "module",
|
|
21
|
-
"files": [
|
|
22
|
-
"extensions",
|
|
23
|
-
"checks",
|
|
24
|
-
"README.md",
|
|
25
|
-
"patch.mjs",
|
|
26
|
-
"LICENSE"
|
|
27
|
-
],
|
|
28
|
-
"pi": {
|
|
29
|
-
"extensions": [
|
|
30
|
-
"./extensions"
|
|
31
|
-
]
|
|
32
|
-
},
|
|
33
|
-
"peerDependencies": {
|
|
34
|
-
"@earendil-works/pi-coding-agent": "*",
|
|
35
|
-
"@earendil-works/pi-tui": "*"
|
|
36
|
-
}
|
|
37
|
-
}
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
// pi-umbra-skill-matcher needs 4 seams that pi's extension API does not expose, so it edits
|
|
2
|
-
// pi's built bundle. Run it after installing, and again after every pi update:
|
|
3
|
-
//
|
|
4
|
-
// node <this file> apply
|
|
5
|
-
// node <this file> --check report only, write nothing, exit 1 if anything is missing
|
|
6
|
-
//
|
|
7
|
-
// pi runs dist/bundle/cli.js, a self-contained bundle, and the chunk names are content-hashed,
|
|
8
|
-
// so every patch finds its own place by matching its own text rather than by line number.
|
|
9
|
-
//
|
|
10
|
-
// Nothing here is destructive. Each patch is additive, a patch whose text no longer matches is
|
|
11
|
-
// reported instead of forced, and reinstalling pi returns the bundle to stock. A pi upgrade
|
|
12
|
-
// replaces the bundle and drops every patch silently, which is what --check is for.
|
|
13
|
-
import { execSync } from "node:child_process";
|
|
14
|
-
import { createRequire } from "node:module";
|
|
15
|
-
import { existsSync, realpathSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
|
-
import { homedir } from "node:os";
|
|
17
|
-
import { dirname, join } from "node:path";
|
|
18
|
-
|
|
19
|
-
// PI_UMBRA_PI points this at another pi tree. Otherwise the pi that runs is the one to patch:
|
|
20
|
-
// the `pi` on PATH, followed through its symlink. Windows puts a .cmd shim there instead, so
|
|
21
|
-
// npm's global root (%APPDATA%\npm\node_modules) comes next, then bun's. The copy npm installs
|
|
22
|
-
// beside this package as a peer dependency is last: pi never runs it. The first that has a built
|
|
23
|
-
// bundle wins.
|
|
24
|
-
const resolvePi = () => {
|
|
25
|
-
if (process.env.PI_UMBRA_PI) return process.env.PI_UMBRA_PI;
|
|
26
|
-
const quiet = { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 20_000 };
|
|
27
|
-
const candidates = [];
|
|
28
|
-
try {
|
|
29
|
-
const bin = execSync(process.platform === "win32" ? "where pi" : "command -v pi", quiet).split(/\r?\n/)[0].trim();
|
|
30
|
-
for (let dir = dirname(realpathSync(bin)); dir !== dirname(dir); dir = dirname(dir)) candidates.push(dir);
|
|
31
|
-
} catch {}
|
|
32
|
-
try {
|
|
33
|
-
const root = execSync("npm root -g", quiet).trim();
|
|
34
|
-
if (root) candidates.push(join(root, "@earendil-works", "pi-coding-agent"));
|
|
35
|
-
} catch {}
|
|
36
|
-
candidates.push(join(homedir(), ".bun", "install", "global", "node_modules", "@earendil-works", "pi-coding-agent"));
|
|
37
|
-
try {
|
|
38
|
-
candidates.push(dirname(createRequire(import.meta.url).resolve("@earendil-works/pi-coding-agent/package.json")));
|
|
39
|
-
} catch {}
|
|
40
|
-
return candidates.find((dir) => existsSync(join(dir, "dist", "bundle"))) ?? candidates[0];
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
const BUNDLE = join(resolvePi(), "dist/bundle");
|
|
44
|
-
if (!existsSync(BUNDLE)) {
|
|
45
|
-
console.error(`no pi bundle at ${BUNDLE} - set PI_UMBRA_PI to your pi install`);
|
|
46
|
-
process.exit(1);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const PATCHES = [
|
|
50
|
-
{
|
|
51
|
-
// A skill is reachable as /skill:huh only. The prefix is hardcoded at both sites below,
|
|
52
|
-
// with no setting for it. The dispatch map now holds each skill under both names, so
|
|
53
|
-
// /huh works and any /skill:huh already in muscle memory or in a doc keeps working.
|
|
54
|
-
// Only the bare name is offered in autocomplete, to keep the list one line per skill.
|
|
55
|
-
reason: "/huh instead of /skill:huh",
|
|
56
|
-
from: "let commandName=`skill:${skill.name}`;this.skillCommands.set(commandName,skill.filePath),skillCommandList.push({name:commandName,",
|
|
57
|
-
to: "let commandName=skill.name;this.skillCommands.set(`skill:${skill.name}`,skill.filePath),this.skillCommands.set(commandName,skill.filePath),skillCommandList.push({name:commandName,",
|
|
58
|
-
},
|
|
59
|
-
{
|
|
60
|
-
reason: "the command list names skills without the prefix too",
|
|
61
|
-
from: "skills=this._resourceLoader.getSkills().skills.map(skill=>({name:`skill:${skill.name}`,",
|
|
62
|
-
to: "skills=this._resourceLoader.getSkills().skills.map(skill=>({name:skill.name,",
|
|
63
|
-
},
|
|
64
|
-
{
|
|
65
|
-
// The slash menu only ever opens on a line that starts with "/": `isAtStartOfMessage`
|
|
66
|
-
// gates the keystroke and `isInSlashCommandContext` gates every letter after it. A
|
|
67
|
-
// "/name" written in the middle of a sentence therefore reaches no provider at all, and
|
|
68
|
-
// an extension cannot fix that - it is never asked. These two open the door;
|
|
69
|
-
// umbra-skill-matcher decides what stands behind it.
|
|
70
|
-
//
|
|
71
|
-
// `umbraMidSlash` is deliberately narrow, so a path keeps behaving like a path: the
|
|
72
|
-
// token must be the last one on the line, hold no space, and sit after real text that
|
|
73
|
-
// ends in whitespace. "src/foo" fails on the last clause, " /foo" on the one before it.
|
|
74
|
-
// `head!==head.trimEnd()` is how "ends in a space or a tab" is written without putting an
|
|
75
|
-
// escape inside a patch string.
|
|
76
|
-
reason: "a mid-sentence /name opens the slash menu",
|
|
77
|
-
from: `isInSlashCommandContext(textBeforeCursor){return this.isSlashMenuAllowed()&&textBeforeCursor.trimStart().startsWith("/")}`,
|
|
78
|
-
to: `isInSlashCommandContext(textBeforeCursor){return this.isSlashMenuAllowed()&&textBeforeCursor.trimStart().startsWith("/")||this.umbraMidSlash(textBeforeCursor)}umbraMidSlash(t){let i=t.lastIndexOf("/");if(i<1)return!1;if(t.slice(i+1).indexOf(" ")>=0)return!1;let head=t.slice(0,i);return head.trim()!==""&&head!==head.trimEnd()}`,
|
|
79
|
-
},
|
|
80
|
-
{
|
|
81
|
-
// The companion to the patch above: without it the menu opens on the second character
|
|
82
|
-
// rather than on the slash, because typing "/" is handled by its own branch that ends at
|
|
83
|
-
// `isAtStartOfMessage`. At that moment the token after the slash is still empty, which is
|
|
84
|
-
// exactly the shape `umbraMidSlash` accepts.
|
|
85
|
-
reason: "typing a mid-sentence slash opens the menu on the slash",
|
|
86
|
-
from: `char==="/"&&this.isAtStartOfMessage()`,
|
|
87
|
-
to: `char==="/"&&(this.isAtStartOfMessage()||this.umbraMidSlash((this.state.lines[this.state.cursorLine]||"").slice(0,this.state.cursorCol)))`,
|
|
88
|
-
},
|
|
89
|
-
];
|
|
90
|
-
|
|
91
|
-
const files = [join(BUNDLE, "cli.js"), ...readdirSync(join(BUNDLE, "chunks")).map((name) => join(BUNDLE, "chunks", name))];
|
|
92
|
-
const CHECK = process.argv.includes("--check");
|
|
93
|
-
const unapplied = [];
|
|
94
|
-
const gone = [];
|
|
95
|
-
|
|
96
|
-
for (const patch of PATCHES) {
|
|
97
|
-
let done = false;
|
|
98
|
-
for (const path of files) {
|
|
99
|
-
const source = readFileSync(path, "utf8");
|
|
100
|
-
// A multi-line patch is written with newline escapes; match whatever line ending the file uses.
|
|
101
|
-
const eol = source.includes("\r\n") ? "\r\n" : "\n";
|
|
102
|
-
const from = patch.from.split("\n").join(eol);
|
|
103
|
-
const to = patch.to.split("\n").join(eol);
|
|
104
|
-
if (source.includes(to)) {
|
|
105
|
-
if (!CHECK) console.log(`already patched: ${patch.reason}`);
|
|
106
|
-
done = true;
|
|
107
|
-
break;
|
|
108
|
-
}
|
|
109
|
-
if (!source.includes(from)) continue;
|
|
110
|
-
if (CHECK) {
|
|
111
|
-
unapplied.push(patch.reason);
|
|
112
|
-
done = true;
|
|
113
|
-
break;
|
|
114
|
-
}
|
|
115
|
-
writeFileSync(path, source.replace(from, to));
|
|
116
|
-
console.log(`patched: ${patch.reason}`);
|
|
117
|
-
done = true;
|
|
118
|
-
break;
|
|
119
|
-
}
|
|
120
|
-
// Neither the original text nor the patched text is there, so pi changed the code this
|
|
121
|
-
// patch names. Report it rather than force anything.
|
|
122
|
-
if (!done) gone.push(patch.reason);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
for (const reason of unapplied) console.log(`NOT APPLIED: ${reason}`);
|
|
126
|
-
for (const reason of gone) console.log(`SEAM GONE: ${reason}`);
|
|
127
|
-
const bad = unapplied.length + gone.length;
|
|
128
|
-
console.log(bad ? `${bad} of ${PATCHES.length} not in place` : `all ${PATCHES.length} patches applied`);
|
|
129
|
-
process.exit(bad ? 1 : 0);
|