@xynogen/pix-core 0.1.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/LICENSE +21 -0
- package/README.md +36 -0
- package/package.json +42 -0
- package/skills/ask-user/SKILL.md +48 -0
- package/src/commands/clear/clear.ts +32 -0
- package/src/commands/copy-all/copy-all.ts +89 -0
- package/src/commands/diff/diff.ts +138 -0
- package/src/commands/lg/lg.ts +32 -0
- package/src/commands/models/models.test.ts +95 -0
- package/src/commands/models/models.ts +362 -0
- package/src/commands/tools.test.ts +15 -0
- package/src/commands/update/update.test.ts +112 -0
- package/src/commands/update/update.ts +271 -0
- package/src/commands/yeet/yeet.ts +29 -0
- package/src/index.ts +49 -0
- package/src/lib/data.ts +241 -0
- package/src/nudge/capability.test.ts +198 -0
- package/src/nudge/capability.ts +152 -0
- package/src/nudge/index.ts +17 -0
- package/src/nudge/tools.test.ts +145 -0
- package/src/nudge/tools.ts +214 -0
- package/src/tool/ask/ask.test.ts +232 -0
- package/src/tool/ask/ask.ts +1081 -0
- package/src/tool/ask/single-select-layout.test.ts +108 -0
- package/src/tool/ask/single-select-layout.ts +203 -0
- package/src/tool/todo/todo.test.ts +602 -0
- package/src/tool/todo/todo.ts +194 -0
- package/src/tool/toolbox/toolbox.test.ts +312 -0
- package/src/tool/toolbox/toolbox.ts +563 -0
- package/src/ui/diagnostics.ts +148 -0
- package/src/ui/footer.ts +513 -0
- package/src/ui/welcome.test.ts +124 -0
- package/src/ui/welcome.ts +369 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xynogen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# pix-core
|
|
2
|
+
|
|
3
|
+
Pi coding agent extension — core UI/UX bundle.
|
|
4
|
+
|
|
5
|
+
## What's included
|
|
6
|
+
|
|
7
|
+
| Extension | Type | Description |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| `welcome` | lifecycle | ASCII π banner + startup health checks (version, auth, models, gitignore) |
|
|
10
|
+
| `footer` | UI | Status bar: mode / git branch / model / cost / tps |
|
|
11
|
+
| `models` | command | `/models` — enhanced model picker with BenchLM rank, context, cost |
|
|
12
|
+
| `update` | command | `/update` — self-update Pi + refresh extensions from dotfiles |
|
|
13
|
+
| `lg` | command | `/lg` — summarize unstaged git changes with per-file +/- counts |
|
|
14
|
+
| `yeet` | command | `/yeet` — stage all, commit, and push current changes |
|
|
15
|
+
| `copy-all` | command | `/copy-all` — copy the whole conversation to the clipboard |
|
|
16
|
+
| `diff` | command | `/diff` — list/open files changed during the last agent run |
|
|
17
|
+
| `todo` | tool | durable execution checklist (survives compaction) |
|
|
18
|
+
| `toolbox` | tool | `search` (fuzzy-find every tool / MCP tool / skill / command), `enable` / `disable` (turn a gated tool on/off by name) |
|
|
19
|
+
| `nudges` | hooks | model-steering reminders (tools / skill / capability) |
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pi install git:github.com/xynogen/pix-core
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
> **Requires** [pix-data](https://github.com/xynogen/pix-data) for shared models.dev + BenchLM cache.
|
|
28
|
+
> Install both:
|
|
29
|
+
> ```bash
|
|
30
|
+
> pi install git:github.com/xynogen/pix-data
|
|
31
|
+
> pi install git:github.com/xynogen/pix-core
|
|
32
|
+
> ```
|
|
33
|
+
|
|
34
|
+
## License
|
|
35
|
+
|
|
36
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xynogen/pix-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension — core UI/UX bundle (welcome banner, footer, model picker, self-update)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "bun test"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"skills",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"pi": {
|
|
17
|
+
"extensions": ["src/index.ts"],
|
|
18
|
+
"skills": ["./skills"]
|
|
19
|
+
},
|
|
20
|
+
"keywords": ["pi", "pi-package", "pi-extension", "pix"],
|
|
21
|
+
"author": "xynogen",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/xynogen/pix-mono.git",
|
|
26
|
+
"directory": "packages/pix-core"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/xynogen/pix-mono/tree/main/packages/pix-core#readme",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/xynogen/pix-mono/issues"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"typebox": "^1.1.38"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
40
|
+
"@earendil-works/pi-tui": "*"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ask-user
|
|
3
|
+
description: "MUST use before high-stakes/irreversible decisions or when requirements are ambiguous. Gather context, present 2-5 options via ask_user, get explicit choice, then proceed."
|
|
4
|
+
metadata:
|
|
5
|
+
short-description: Decision gate for ambiguity and high-stakes choices
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# ask_user decision gate
|
|
9
|
+
|
|
10
|
+
Decision control, not chit-chat.
|
|
11
|
+
|
|
12
|
+
## Gate (call ask_user before proceeding if ANY true)
|
|
13
|
+
- changes architecture/schema/API/deploy/security
|
|
14
|
+
- costly to undo (big refactor, migration, destructive edit, prod behavior)
|
|
15
|
+
- requirements unclear/conflicting/missing
|
|
16
|
+
- multiple valid options, trade-off is preference-dependent
|
|
17
|
+
- about to assume something that changes implementation
|
|
18
|
+
|
|
19
|
+
Skip only if user already gave an explicit decision for THIS exact trade-off.
|
|
20
|
+
|
|
21
|
+
## Handshake
|
|
22
|
+
1. classify step: high_stakes | ambiguous | both | clear. clear → no gate.
|
|
23
|
+
2. gather evidence first (read/bash/web/ref). don't ask blind.
|
|
24
|
+
3. synthesize: 3-7 bullets — state, constraints, trade-offs, recommendation.
|
|
25
|
+
4. ask ONE focused question: `question`, `context`(summary), `options`(2-5),
|
|
26
|
+
`allowMultiple:false` unless truly independent, `allowFreeform:true`.
|
|
27
|
+
`inline` displayMode when the preceding summary must stay visible.
|
|
28
|
+
5. commit: restate decision, say next step, proceed.
|
|
29
|
+
6. re-ask only on materially new ambiguity. no confirm loops.
|
|
30
|
+
|
|
31
|
+
## Budget (anti-overasking)
|
|
32
|
+
- max 1 call per boundary normally; max 2 if first is unclear/cancelled.
|
|
33
|
+
- never re-ask same trade-off without new evidence.
|
|
34
|
+
- attempt 2 = narrower question: [Proceed w/ recommended] [Choose other (freeform)] [Stop].
|
|
35
|
+
- after attempt 2: high_stakes/both → STOP, mark blocked.
|
|
36
|
+
ambiguous-only + user says "your call" → take most reversible default, state assumptions.
|
|
37
|
+
|
|
38
|
+
## Quality
|
|
39
|
+
- question: concrete decision boundary, one decision only.
|
|
40
|
+
- options: short, outcome-oriented, explicit trade-offs; add description when non-obvious.
|
|
41
|
+
|
|
42
|
+
## Anti-patterns
|
|
43
|
+
asking without context · trivial formatting choices · forcing options when freeform fits ·
|
|
44
|
+
repeat questions w/o new info · proceeding high-stakes after unclear/cancelled answer.
|
|
45
|
+
|
|
46
|
+
## On cancel / unclear
|
|
47
|
+
Pause, explain what's blocked. At most one narrower follow-up.
|
|
48
|
+
Then: high-stakes → stay blocked until explicit decision; ambiguity-only → proceed only if user delegated.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionCommandContext,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
async function clearCache(pi: ExtensionAPI, ctx: ExtensionCommandContext) {
|
|
7
|
+
ctx.ui.notify("Clearing ~/.cache/pi", "info");
|
|
8
|
+
const result = await pi.exec("/bin/sh", ["-lc", 'rm -rf "$HOME/.cache/pi"'], {
|
|
9
|
+
timeout: 10_000,
|
|
10
|
+
});
|
|
11
|
+
const output = [result.stdout, result.stderr]
|
|
12
|
+
.filter(Boolean)
|
|
13
|
+
.join("\n")
|
|
14
|
+
.trim();
|
|
15
|
+
if ((result.exitCode ?? 0) !== 0) {
|
|
16
|
+
ctx.ui.notify(`Cache clear failed. ${output || "No output."}`, "error");
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
ctx.ui.notify(
|
|
20
|
+
"~/.cache/pi cleared. Run /reload to apply changes.",
|
|
21
|
+
"warning",
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default function (pi: ExtensionAPI) {
|
|
26
|
+
pi.registerCommand("clear", {
|
|
27
|
+
description: "Remove ~/.cache/pi and reload",
|
|
28
|
+
handler: async (_args, ctx) => {
|
|
29
|
+
await clearCache(pi, ctx);
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copy-All Extension
|
|
3
|
+
*
|
|
4
|
+
* /copy-all → copies the entire user+assistant conversation in the current
|
|
5
|
+
* branch to the system clipboard. Uses pbcopy on macOS, xclip/xsel/wl-copy
|
|
6
|
+
* on Linux, clip.exe on WSL/Windows.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { platform } from "node:os";
|
|
11
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
|
|
13
|
+
function textFromContent(content: unknown): string {
|
|
14
|
+
if (typeof content === "string") return content;
|
|
15
|
+
if (!Array.isArray(content)) return "";
|
|
16
|
+
return content
|
|
17
|
+
.map((block) => {
|
|
18
|
+
if (!block || typeof block !== "object" || !("type" in block)) return "";
|
|
19
|
+
if (block.type === "text" && "text" in block && typeof block.text === "string") return block.text;
|
|
20
|
+
if (block.type === "image") return "[image]";
|
|
21
|
+
return "";
|
|
22
|
+
})
|
|
23
|
+
.filter(Boolean)
|
|
24
|
+
.join("\n");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function pickClipboardCmd(): { cmd: string; args: string[] } | undefined {
|
|
28
|
+
const p = platform();
|
|
29
|
+
if (p === "darwin") return { cmd: "pbcopy", args: [] };
|
|
30
|
+
if (p === "win32") return { cmd: "clip.exe", args: [] };
|
|
31
|
+
// linux / wsl
|
|
32
|
+
if (process.env.WSL_DISTRO_NAME) return { cmd: "clip.exe", args: [] };
|
|
33
|
+
if (process.env.WAYLAND_DISPLAY) return { cmd: "wl-copy", args: [] };
|
|
34
|
+
return { cmd: "xclip", args: ["-selection", "clipboard"] };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function copyToClipboard(text: string): Promise<void> {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const c = pickClipboardCmd();
|
|
40
|
+
if (!c) {
|
|
41
|
+
reject(new Error("No clipboard utility detected"));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const child = spawn(c.cmd, c.args);
|
|
45
|
+
let stderr = "";
|
|
46
|
+
child.stderr.on("data", (chunk) => { stderr += String(chunk); });
|
|
47
|
+
child.on("error", reject);
|
|
48
|
+
child.on("close", (code) => {
|
|
49
|
+
if (code === 0) resolve();
|
|
50
|
+
else reject(new Error(stderr.trim() || `${c.cmd} exited with code ${code}`));
|
|
51
|
+
});
|
|
52
|
+
child.stdin.end(text);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export default function (pi: ExtensionAPI) {
|
|
57
|
+
pi.registerCommand("copy-all", {
|
|
58
|
+
description: "Copy all user/assistant messages in this thread to the clipboard",
|
|
59
|
+
handler: async (_args, ctx) => {
|
|
60
|
+
await ctx.waitForIdle();
|
|
61
|
+
|
|
62
|
+
const messages = ctx.sessionManager
|
|
63
|
+
.getBranch()
|
|
64
|
+
.filter((entry) => entry.type === "message")
|
|
65
|
+
.map((entry) => entry.message)
|
|
66
|
+
.filter((m) => m.role === "user" || m.role === "assistant");
|
|
67
|
+
|
|
68
|
+
const text = messages
|
|
69
|
+
.map((m) => {
|
|
70
|
+
const c = textFromContent(m.content).trim();
|
|
71
|
+
return `${m.role.toUpperCase()}:\n${c}`;
|
|
72
|
+
})
|
|
73
|
+
.filter((s) => !s.endsWith(":\n"))
|
|
74
|
+
.join("\n\n---\n\n");
|
|
75
|
+
|
|
76
|
+
if (!text) {
|
|
77
|
+
ctx.ui.notify("No user or assistant messages to copy", "info");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
await copyToClipboard(text);
|
|
83
|
+
ctx.ui.notify(`📋 Copied ${messages.length} messages to clipboard`, "info");
|
|
84
|
+
} catch (err) {
|
|
85
|
+
ctx.ui.notify(`Clipboard copy failed: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diff Extension
|
|
3
|
+
*
|
|
4
|
+
* Tracks files changed during the last agent run (git delta + tool-touched
|
|
5
|
+
* paths from edit/write), and exposes /diff to list/open them.
|
|
6
|
+
*
|
|
7
|
+
* Subcommands:
|
|
8
|
+
* /diff → interactive selector, opens choice in editor
|
|
9
|
+
* /diff list → notify with the list of changed files
|
|
10
|
+
* /diff clear → reset tracked set and re-baseline against git
|
|
11
|
+
*
|
|
12
|
+
* Editor: $PI_DIFF_EDITOR > $VISUAL > $EDITOR > zed > code > vim
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
|
|
18
|
+
const COMMAND = "diff";
|
|
19
|
+
|
|
20
|
+
function getStringPath(input: unknown): string | undefined {
|
|
21
|
+
if (!input || typeof input !== "object" || !("path" in input)) return undefined;
|
|
22
|
+
const p = (input as { path?: unknown }).path;
|
|
23
|
+
return typeof p === "string" ? p : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toAbs(cwd: string, p: string): string {
|
|
27
|
+
return path.isAbsolute(p) ? path.normalize(p) : path.resolve(cwd, p);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function toRel(cwd: string, p: string): string {
|
|
31
|
+
const r = path.relative(cwd, p);
|
|
32
|
+
return r && !r.startsWith("..") && !path.isAbsolute(r) ? r : p;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseGitStatus(output: string, cwd: string): Set<string> {
|
|
36
|
+
const files = new Set<string>();
|
|
37
|
+
for (const line of output.split("\n")) {
|
|
38
|
+
if (line.length < 4) continue;
|
|
39
|
+
const raw = line.slice(3).trim();
|
|
40
|
+
if (!raw) continue;
|
|
41
|
+
const target = raw.includes(" -> ") ? raw.split(" -> ").at(-1) : raw;
|
|
42
|
+
if (!target) continue;
|
|
43
|
+
files.add(toAbs(cwd, target.replace(/^"|"$/g, "")));
|
|
44
|
+
}
|
|
45
|
+
return files;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function getGitChanged(pi: ExtensionAPI, cwd: string): Promise<Set<string>> {
|
|
49
|
+
const r = await pi.exec("git", ["status", "--porcelain", "--untracked-files=all"], { cwd, timeout: 5000 });
|
|
50
|
+
if (r.code !== 0) return new Set();
|
|
51
|
+
return parseGitStatus(r.stdout, cwd);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function diff(current: Set<string>, baseline: Set<string>): Set<string> {
|
|
55
|
+
return new Set([...current].filter((f) => !baseline.has(f)));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function pickEditor(): { cmd: string; args: (file: string) => string[] } {
|
|
59
|
+
const env = process.env.PI_DIFF_EDITOR || process.env.VISUAL || process.env.EDITOR;
|
|
60
|
+
if (env) {
|
|
61
|
+
const parts = env.split(/\s+/);
|
|
62
|
+
const cmd = parts[0];
|
|
63
|
+
const rest = parts.slice(1);
|
|
64
|
+
return { cmd, args: (f) => [...rest, f] };
|
|
65
|
+
}
|
|
66
|
+
return { cmd: "zed", args: (f) => ["-e", f] };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export default function (pi: ExtensionAPI) {
|
|
70
|
+
let baseline = new Set<string>();
|
|
71
|
+
let changed = new Set<string>();
|
|
72
|
+
let touched = new Set<string>();
|
|
73
|
+
|
|
74
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
75
|
+
touched = new Set();
|
|
76
|
+
changed = new Set();
|
|
77
|
+
baseline = await getGitChanged(pi, ctx.cwd);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
pi.on("tool_result", (event, ctx) => {
|
|
81
|
+
if (event.toolName !== "edit" && event.toolName !== "write") return;
|
|
82
|
+
const p = getStringPath(event.input);
|
|
83
|
+
if (!p) return;
|
|
84
|
+
touched.add(toAbs(ctx.cwd, p));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
88
|
+
const now = await getGitChanged(pi, ctx.cwd);
|
|
89
|
+
changed = new Set([...diff(now, baseline), ...touched]);
|
|
90
|
+
if (changed.size > 0) {
|
|
91
|
+
ctx.ui.notify(`📝 ${changed.size} changed file(s). Run /${COMMAND} to view/open.`, "info");
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
pi.registerCommand(COMMAND, {
|
|
96
|
+
description: "Show files changed by the last agent run and open one in your editor",
|
|
97
|
+
handler: async (args, ctx) => {
|
|
98
|
+
await ctx.waitForIdle();
|
|
99
|
+
const arg = (args ?? "").trim();
|
|
100
|
+
|
|
101
|
+
if (arg === "clear") {
|
|
102
|
+
changed = new Set();
|
|
103
|
+
touched = new Set();
|
|
104
|
+
baseline = await getGitChanged(pi, ctx.cwd);
|
|
105
|
+
ctx.ui.notify("Cleared changed file list", "info");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const files = [...changed].sort((a, b) => toRel(ctx.cwd, a).localeCompare(toRel(ctx.cwd, b)));
|
|
110
|
+
if (files.length === 0) {
|
|
111
|
+
ctx.ui.notify("No changed files tracked from the last agent run", "info");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (arg === "list") {
|
|
116
|
+
ctx.ui.notify(`Changed files:\n${files.map((f) => `- ${toRel(ctx.cwd, f)}`).join("\n")}`, "info");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (arg) {
|
|
121
|
+
ctx.ui.notify(`Unknown /${COMMAND} argument: ${arg}. Try /${COMMAND}, /${COMMAND} list, /${COMMAND} clear.`, "warning");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const labels = files.map((f) => toRel(ctx.cwd, f));
|
|
126
|
+
const selected = await ctx.ui.select("Open changed file", labels);
|
|
127
|
+
if (!selected) return;
|
|
128
|
+
|
|
129
|
+
const file = files[labels.indexOf(selected)];
|
|
130
|
+
if (!file) return;
|
|
131
|
+
|
|
132
|
+
const ed = pickEditor();
|
|
133
|
+
const r = await pi.exec(ed.cmd, ed.args(file), { cwd: ctx.cwd, timeout: 5000 });
|
|
134
|
+
if (r.code === 0) ctx.ui.notify(`Opened ${selected} in ${ed.cmd}`, "info");
|
|
135
|
+
else ctx.ui.notify(r.stderr.trim() || `Failed to open ${selected} in ${ed.cmd}`, "error");
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /lg — summarize unstaged git changes with per-file +/- counts.
|
|
3
|
+
*
|
|
4
|
+
* The agent runs `git status` + line counts and replies with:
|
|
5
|
+
* 1. 1–2 sentence summary of unstaged changes
|
|
6
|
+
* 2. Per-file +/- line counts
|
|
7
|
+
* 3. Total +/- line count
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
|
|
12
|
+
const LG_PROMPT = `Run git status, inspect what has changed, then respond with only:
|
|
13
|
+
|
|
14
|
+
1. A short 1-2 sentence summary of the unstaged changes.
|
|
15
|
+
2. A list of changed unstaged files with their +/- line counts.
|
|
16
|
+
3. A total +/- line count at the bottom.
|
|
17
|
+
|
|
18
|
+
Keep it concise. Use git commands to calculate the line counts; do not include staged changes unless they also have unstaged modifications.`;
|
|
19
|
+
|
|
20
|
+
export default function (pi: ExtensionAPI) {
|
|
21
|
+
pi.registerCommand("lg", {
|
|
22
|
+
description: "Summarize unstaged git changes with per-file +/- counts",
|
|
23
|
+
handler: async (_args, ctx) => {
|
|
24
|
+
if (!ctx.isIdle()) {
|
|
25
|
+
pi.sendUserMessage(LG_PROMPT, { deliverAs: "followUp" });
|
|
26
|
+
ctx.ui.notify("Queued /lg after the current turn finishes.", "info");
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
pi.sendUserMessage(LG_PROMPT);
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { fmtCtx, fmtCost, benchStars, sortModels } from "./models.ts";
|
|
3
|
+
|
|
4
|
+
describe("fmtCtx", () => {
|
|
5
|
+
it("formats 0 as 0", () => expect(fmtCtx(0)).toBe("0"));
|
|
6
|
+
it("formats small numbers as-is", () => expect(fmtCtx(512)).toBe("512"));
|
|
7
|
+
it("formats thousands as Nk", () => {
|
|
8
|
+
expect(fmtCtx(128_000)).toBe("128k");
|
|
9
|
+
expect(fmtCtx(8_192)).toBe("8k");
|
|
10
|
+
});
|
|
11
|
+
it("formats millions as NM", () => {
|
|
12
|
+
expect(fmtCtx(1_000_000)).toBe("1M");
|
|
13
|
+
expect(fmtCtx(2_000_000)).toBe("2M");
|
|
14
|
+
expect(fmtCtx(1_500_000)).toBe("1.5M");
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("fmtCost", () => {
|
|
19
|
+
it("returns — for undefined entry", () =>
|
|
20
|
+
expect(fmtCost(undefined)).toBe("—"));
|
|
21
|
+
it("returns — when no cost field", () => expect(fmtCost({})).toBe("—"));
|
|
22
|
+
it("returns free when both 0", () => {
|
|
23
|
+
expect(fmtCost({ cost: { input: 0, output: 0 } })).toBe("free");
|
|
24
|
+
});
|
|
25
|
+
it("formats input/output costs", () => {
|
|
26
|
+
expect(fmtCost({ cost: { input: 3, output: 15 } })).toBe("3.00/15.00");
|
|
27
|
+
});
|
|
28
|
+
it("handles missing input/output as 0", () => {
|
|
29
|
+
expect(fmtCost({ cost: {} })).toBe("free");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("benchStars", () => {
|
|
34
|
+
it("gives 5 stars for score >= 90", () => {
|
|
35
|
+
expect(benchStars(95).filled).toBe(5);
|
|
36
|
+
expect(benchStars(90).filled).toBe(5);
|
|
37
|
+
});
|
|
38
|
+
it("gives 4 stars for 80-89", () => {
|
|
39
|
+
expect(benchStars(85).filled).toBe(4);
|
|
40
|
+
expect(benchStars(80).filled).toBe(4);
|
|
41
|
+
});
|
|
42
|
+
it("gives 3 stars for 70-79", () => {
|
|
43
|
+
expect(benchStars(75).filled).toBe(3);
|
|
44
|
+
});
|
|
45
|
+
it("gives 2 stars for 50-69", () => {
|
|
46
|
+
expect(benchStars(60).filled).toBe(2);
|
|
47
|
+
expect(benchStars(50).filled).toBe(2);
|
|
48
|
+
});
|
|
49
|
+
it("gives 1 star for score < 50", () => {
|
|
50
|
+
expect(benchStars(30).filled).toBe(1);
|
|
51
|
+
expect(benchStars(0).filled).toBe(1);
|
|
52
|
+
});
|
|
53
|
+
it("gives 1 star for null/undefined", () => {
|
|
54
|
+
expect(benchStars(null).filled).toBe(1);
|
|
55
|
+
expect(benchStars(undefined).filled).toBe(1);
|
|
56
|
+
});
|
|
57
|
+
it("filled + empty always = 5", () => {
|
|
58
|
+
for (const s of [0, 50, 70, 80, 90, 100]) {
|
|
59
|
+
const { filled, empty } = benchStars(s);
|
|
60
|
+
expect(filled + empty).toBe(5);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("sortModels", () => {
|
|
66
|
+
const models = [
|
|
67
|
+
{ provider: "a", id: "m1", name: "Zebra", score: 80 },
|
|
68
|
+
{ provider: "a", id: "m2", name: "Alpha", score: 95 },
|
|
69
|
+
{ provider: "a", id: "m3", name: "Middle", score: null },
|
|
70
|
+
{ provider: "a", id: "m4", name: "Beta", score: 80 },
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
it("sorts by score descending", () => {
|
|
74
|
+
const sorted = sortModels(models);
|
|
75
|
+
expect(sorted[0].name).toBe("Alpha"); // score 95
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("breaks score ties alphabetically by name", () => {
|
|
79
|
+
const sorted = sortModels(models);
|
|
80
|
+
const tiedIdx = sorted.findIndex((m) => m.name === "Beta");
|
|
81
|
+
const zebraIdx = sorted.findIndex((m) => m.name === "Zebra");
|
|
82
|
+
expect(tiedIdx).toBeLessThan(zebraIdx); // Beta before Zebra, both score 80
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("puts null score models last", () => {
|
|
86
|
+
const sorted = sortModels(models);
|
|
87
|
+
expect(sorted[sorted.length - 1].name).toBe("Middle");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("does not mutate the original array", () => {
|
|
91
|
+
const original = [...models];
|
|
92
|
+
sortModels(models);
|
|
93
|
+
expect(models).toEqual(original);
|
|
94
|
+
});
|
|
95
|
+
});
|