pi-soly 2.4.2 → 2.5.1
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/mcp/mcp-panel.ts +12 -44
- package/mcp/mcp-setup-panel.ts +14 -16
- package/package.json +1 -1
- package/skills/agent-coach/SKILL.md +250 -0
- package/visual/border.ts +64 -0
- package/visual/fuzzy.ts +26 -0
- package/visual/list-panel.ts +4 -11
package/mcp/mcp-panel.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
2
|
import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "../visual/panel-keys.ts";
|
|
3
|
+
import { fuzzyScore } from "../visual/fuzzy.ts";
|
|
4
|
+
import { borderTop, borderBottom, borderDivider, borderRow, borderEmpty, type BorderStyler } from "../visual/border.ts";
|
|
3
5
|
import { isToolExcluded } from "./types.ts";
|
|
4
6
|
import type { McpConfig, McpPanelCallbacks, McpPanelResult, ServerProvenance } from "./types.ts";
|
|
5
7
|
import { resourceNameToToolName } from "./resource-tools.ts";
|
|
@@ -55,24 +57,7 @@ function rainbowProgress(filled: number, total: number): string {
|
|
|
55
57
|
return dots.join(" ");
|
|
56
58
|
}
|
|
57
59
|
|
|
58
|
-
|
|
59
|
-
const lq = query.toLowerCase();
|
|
60
|
-
const lt = text.toLowerCase();
|
|
61
|
-
if (lt.includes(lq)) return 100 + (lq.length / lt.length) * 50;
|
|
62
|
-
let score = 0;
|
|
63
|
-
let qi = 0;
|
|
64
|
-
let consecutive = 0;
|
|
65
|
-
for (let i = 0; i < lt.length && qi < lq.length; i++) {
|
|
66
|
-
if (lt[i] === lq[qi]) {
|
|
67
|
-
score += 10 + consecutive;
|
|
68
|
-
consecutive += 5;
|
|
69
|
-
qi++;
|
|
70
|
-
} else {
|
|
71
|
-
consecutive = 0;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
return qi === lq.length ? score : 0;
|
|
75
|
-
}
|
|
60
|
+
// fuzzyScore moved to ../visual/fuzzy.ts — imported above.
|
|
76
61
|
|
|
77
62
|
function estimateTokens(tool: CachedTool): number {
|
|
78
63
|
const schemaLen = JSON.stringify(tool.inputSchema ?? {}).length;
|
|
@@ -599,18 +584,15 @@ class McpPanel {
|
|
|
599
584
|
const italic = (s: string) => `\x1b[3m${s}\x1b[23m`;
|
|
600
585
|
const inverse = (s: string) => `\x1b[7m${s}\x1b[27m`;
|
|
601
586
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
const
|
|
605
|
-
const
|
|
587
|
+
// Shared border styler — colors border chars via the panel theme.
|
|
588
|
+
const borderS: BorderStyler = (s: string) => fg(t.border, s);
|
|
589
|
+
const titleS: BorderStyler = (s: string) => fg(t.title, s);
|
|
590
|
+
const row = (content: string) => borderRow(content, innerW, borderS);
|
|
591
|
+
const emptyRow = () => borderEmpty(innerW, borderS);
|
|
592
|
+
const divider = () => borderDivider(innerW, borderS);
|
|
606
593
|
|
|
607
|
-
const titleText = this.authOnly ? "
|
|
608
|
-
|
|
609
|
-
const leftB = Math.floor(borderLen / 2);
|
|
610
|
-
const rightB = borderLen - leftB;
|
|
611
|
-
lines.push(fg(t.border, "╭" + "─".repeat(leftB)) + fg(t.title, titleText) + fg(t.border, "─".repeat(rightB) + "╮"));
|
|
612
|
-
|
|
613
|
-
lines.push(emptyRow());
|
|
594
|
+
const titleText = this.authOnly ? "MCP OAuth" : "MCP Servers";
|
|
595
|
+
lines.push(borderTop(titleText, innerW, borderS, titleS));
|
|
614
596
|
|
|
615
597
|
const cursor = fg(t.selected, "│");
|
|
616
598
|
const searchIcon = fg(t.border, "◎");
|
|
@@ -622,27 +604,21 @@ class McpPanel {
|
|
|
622
604
|
lines.push(row(`${searchIcon} ${fg(t.placeholder, italic("search..."))}`));
|
|
623
605
|
}
|
|
624
606
|
|
|
625
|
-
lines.push(emptyRow());
|
|
626
607
|
if (this.noticeLines.length > 0) {
|
|
627
608
|
for (const notice of this.noticeLines) {
|
|
628
609
|
lines.push(row(fg(t.hint, italic(notice))));
|
|
629
610
|
}
|
|
630
|
-
lines.push(emptyRow());
|
|
631
611
|
}
|
|
632
612
|
lines.push(divider());
|
|
633
613
|
|
|
634
614
|
if (this.servers.length === 0) {
|
|
635
|
-
lines.push(emptyRow());
|
|
636
615
|
lines.push(row(fg(t.hint, italic(this.authOnly ? "No OAuth-capable MCP servers configured." : "No MCP servers configured."))));
|
|
637
|
-
lines.push(emptyRow());
|
|
638
616
|
} else {
|
|
639
617
|
const maxVis = McpPanel.MAX_VISIBLE;
|
|
640
618
|
const total = this.visibleItems.length;
|
|
641
619
|
const startIdx = Math.max(0, Math.min(this.cursorIndex - Math.floor(maxVis / 2), total - maxVis));
|
|
642
620
|
const endIdx = Math.min(startIdx + maxVis, total);
|
|
643
621
|
|
|
644
|
-
lines.push(emptyRow());
|
|
645
|
-
|
|
646
622
|
for (let i = startIdx; i < endIdx; i++) {
|
|
647
623
|
const item = this.visibleItems[i];
|
|
648
624
|
const isCursor = i === this.cursorIndex;
|
|
@@ -655,26 +631,20 @@ class McpPanel {
|
|
|
655
631
|
}
|
|
656
632
|
}
|
|
657
633
|
|
|
658
|
-
lines.push(emptyRow());
|
|
659
|
-
|
|
660
634
|
if (total > maxVis) {
|
|
661
635
|
const prog = Math.round(((this.cursorIndex + 1) / total) * 10);
|
|
662
636
|
lines.push(row(`${rainbowProgress(prog, 10)} ${fg(t.hint, `${this.cursorIndex + 1}/${total}`)}`));
|
|
663
|
-
lines.push(emptyRow());
|
|
664
637
|
}
|
|
665
638
|
|
|
666
639
|
if (this.importNotice) {
|
|
667
640
|
lines.push(row(fg(t.needsAuth, italic(this.importNotice))));
|
|
668
|
-
lines.push(emptyRow());
|
|
669
641
|
}
|
|
670
642
|
if (this.authNotice) {
|
|
671
643
|
lines.push(row(fg(t.needsAuth, italic(this.authNotice))));
|
|
672
|
-
lines.push(emptyRow());
|
|
673
644
|
}
|
|
674
645
|
}
|
|
675
646
|
|
|
676
647
|
lines.push(divider());
|
|
677
|
-
lines.push(emptyRow());
|
|
678
648
|
|
|
679
649
|
if (this.confirmingDiscard) {
|
|
680
650
|
const discardBtn = this.discardSelected === 0
|
|
@@ -699,7 +669,6 @@ class McpPanel {
|
|
|
699
669
|
}
|
|
700
670
|
}
|
|
701
671
|
|
|
702
|
-
lines.push(emptyRow());
|
|
703
672
|
const hints = this.authOnly
|
|
704
673
|
? [
|
|
705
674
|
italic("↑↓") + " navigate",
|
|
@@ -738,8 +707,7 @@ class McpPanel {
|
|
|
738
707
|
}
|
|
739
708
|
if (curLine) lines.push(row(fg(t.hint, curLine)));
|
|
740
709
|
|
|
741
|
-
lines.push(
|
|
742
|
-
|
|
710
|
+
lines.push(borderBottom(innerW, borderS));
|
|
743
711
|
return lines;
|
|
744
712
|
}
|
|
745
713
|
|
package/mcp/mcp-setup-panel.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
2
|
import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "../visual/panel-keys.ts";
|
|
3
|
+
import { borderTop, borderBottom, borderDivider, borderRow, borderEmpty, type BorderStyler } from "../visual/border.ts";
|
|
3
4
|
import type { ImportKind } from "./types.ts";
|
|
4
5
|
import type { ConfigWritePreview, McpDiscoverySummary } from "./config.ts";
|
|
5
6
|
import type { McpOnboardingState } from "./onboarding-state.ts";
|
|
@@ -345,22 +346,24 @@ export class McpSetupPanel {
|
|
|
345
346
|
render(width: number): string[] {
|
|
346
347
|
const innerW = Math.max(40, width - 2);
|
|
347
348
|
const lines: string[] = [];
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
lines.push(
|
|
352
|
-
lines.push(
|
|
353
|
-
lines.push(this.
|
|
349
|
+
const borderS: BorderStyler = (s: string) => fg(this.t.border, s);
|
|
350
|
+
const row = (content: string) => borderRow(content, innerW, borderS);
|
|
351
|
+
|
|
352
|
+
lines.push(borderTop("", innerW, borderS));
|
|
353
|
+
lines.push(row(fg(this.t.title, "MCP setup")));
|
|
354
|
+
lines.push(row(this.discoverySummaryLine()));
|
|
355
|
+
lines.push(row(fg(this.t.muted, this.secondarySummaryLine())));
|
|
356
|
+
lines.push(borderEmpty(innerW, borderS));
|
|
354
357
|
|
|
355
358
|
if (this.notice) {
|
|
356
359
|
const tone = this.notice.tone === "success" ? this.t.success : this.notice.tone === "warning" ? this.t.warning : this.t.hint;
|
|
357
360
|
for (const line of wrapText(this.notice.text, innerW - 6)) {
|
|
358
|
-
lines.push(
|
|
361
|
+
lines.push(row(fg(tone, line)));
|
|
359
362
|
}
|
|
360
|
-
lines.push(
|
|
363
|
+
lines.push(borderEmpty(innerW, borderS));
|
|
361
364
|
}
|
|
362
365
|
|
|
363
|
-
lines.push(
|
|
366
|
+
lines.push(borderDivider(innerW, borderS));
|
|
364
367
|
|
|
365
368
|
if (this.screen === "imports") {
|
|
366
369
|
lines.push(...this.renderImports(innerW));
|
|
@@ -370,7 +373,7 @@ export class McpSetupPanel {
|
|
|
370
373
|
lines.push(...this.renderActions(innerW));
|
|
371
374
|
}
|
|
372
375
|
|
|
373
|
-
lines.push(
|
|
376
|
+
lines.push(borderBottom(innerW, borderS));
|
|
374
377
|
return lines;
|
|
375
378
|
}
|
|
376
379
|
|
|
@@ -554,12 +557,7 @@ export class McpSetupPanel {
|
|
|
554
557
|
}
|
|
555
558
|
|
|
556
559
|
private padLine(text: string, innerW: number): string {
|
|
557
|
-
|
|
558
|
-
const contentW = Math.max(0, innerW - inset * 2);
|
|
559
|
-
const fitted = truncateToWidth(text, contentW, "…", true);
|
|
560
|
-
const plainWidth = visibleWidth(fitted);
|
|
561
|
-
const padding = Math.max(0, contentW - plainWidth);
|
|
562
|
-
return `│${" ".repeat(inset)}${fitted}${" ".repeat(padding)}${" ".repeat(inset)}│`;
|
|
560
|
+
return borderRow(text, innerW, (s) => fg(this.t.border, s));
|
|
563
561
|
}
|
|
564
562
|
|
|
565
563
|
invalidate(): void {}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-soly",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.1",
|
|
4
4
|
"description": "Workflow + project management for pi-coding-agent. Plans, state, MANDATORY rules, self-review, multi-question picker. One npm install, zero config. LLM drives the workflow inline via the soly_workflow tool — no external subagent plugin.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: agent-coach
|
|
3
|
+
description: Use when the user complains about how the agent wrote code or behaves — phrases like "переделай X по-другому", "мне не нравится что agent делает Y", "почему опять Z", "не делай так", "agent keeps doing X", "I don't like the agent doing X", "заставь agent-а не делать X", "опять этот god switch". Analyzes the complaint, identifies the missing rule that would have prevented it, and proposes a soly rule draft (.agents/rules/*.md) for the user to confirm via ask_pro. Language-agnostic — works for any codebase. NOT for C#/.NET analyzer rules (use analyzer-coach for that).
|
|
4
|
+
priority: high
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# agent-coach
|
|
8
|
+
|
|
9
|
+
Convert a user complaint about agent behavior into a **soly rule** — a
|
|
10
|
+
markdown instruction file in `.agents/rules/` that goes into the system
|
|
11
|
+
prompt and prevents the agent from repeating the mistake.
|
|
12
|
+
|
|
13
|
+
The feedback loop: user corrects → rule persists → agent doesn't repeat.
|
|
14
|
+
Each complaint is a signal that a rule is **missing** (or exists but the
|
|
15
|
+
agent ignored it — investigate which).
|
|
16
|
+
|
|
17
|
+
## When to use me
|
|
18
|
+
|
|
19
|
+
| Symptom in user's message | Use this skill? |
|
|
20
|
+
|---|---|
|
|
21
|
+
| "переделай X по-другому" / "redo X differently" | **Yes** |
|
|
22
|
+
| "мне не нравится что agent делает Y" / "I don't like the agent doing Y" | **Yes** |
|
|
23
|
+
| "почему опять Z" / "why does it keep doing Z" | **Yes** |
|
|
24
|
+
| "не делай так" / "don't do that" | **Yes** |
|
|
25
|
+
| "agent keeps doing X" / "заставь agent-а не делать X" | **Yes** |
|
|
26
|
+
| Complaint about code style, naming, structure, approach | **Yes** |
|
|
27
|
+
| C#/.NET style complaint (Roslynator/Meziantou/CA-*) | **No** — use `analyzer-coach` |
|
|
28
|
+
| Formatting (indent, EOL, trailing whitespace) | **No** — `.editorconfig` |
|
|
29
|
+
| Code style a linter can check (eslint/biome/prettier) | **No** — linter config |
|
|
30
|
+
| Logic bug in the code | **No** — tests |
|
|
31
|
+
| "this rule fires too much" | **Yes, but inverted** — the rule should go, not be added |
|
|
32
|
+
|
|
33
|
+
## Mental model
|
|
34
|
+
|
|
35
|
+
A complaint means the agent did X, and the user wanted Y. The rule's job is
|
|
36
|
+
to make the agent do Y next time **without the user having to say it**.
|
|
37
|
+
|
|
38
|
+
**Good rule:** "Use `cancellationToken` not `ct` for C# cancellation tokens.
|
|
39
|
+
The short form is ambiguous in logs and stack traces." → Prevents the mistake.
|
|
40
|
+
|
|
41
|
+
**Bad rule:** "Don't write bad code." → Too vague, not actionable, ignored.
|
|
42
|
+
|
|
43
|
+
The rule must be **specific enough to act on** and **general enough to
|
|
44
|
+
recurring**. One complaint → one rule about the pattern, not about the
|
|
45
|
+
specific instance.
|
|
46
|
+
|
|
47
|
+
## Workflow (always follow these steps in order)
|
|
48
|
+
|
|
49
|
+
### Step 1 — Read the complaint + the code
|
|
50
|
+
|
|
51
|
+
Before analyzing, **read**:
|
|
52
|
+
- The user's complaint (what specifically bothers them — extract the
|
|
53
|
+
concrete behavior, not the emotion)
|
|
54
|
+
- The code the agent wrote (the file(s) that triggered the complaint) —
|
|
55
|
+
use `read` / `soly_snippet`
|
|
56
|
+
- The conversation context (what was the agent trying to do?)
|
|
57
|
+
|
|
58
|
+
**Extract the pattern.** "переделай god switch по-другому" is about a
|
|
59
|
+
specific instance, but the underlying pattern might be:
|
|
60
|
+
- "Don't create God classes/switches" (architecture)
|
|
61
|
+
- "Prefer polymorphism over giant switch statements" (design pattern)
|
|
62
|
+
- "Split switches with >5 cases into a dispatch table" (specific threshold)
|
|
63
|
+
|
|
64
|
+
Ask yourself: **what general principle, if the agent had known it, would
|
|
65
|
+
have prevented this?**
|
|
66
|
+
|
|
67
|
+
### Step 2 — Check existing rules (dedup)
|
|
68
|
+
|
|
69
|
+
Before proposing, **read existing rules** to avoid duplication:
|
|
70
|
+
- Use `soly_doc_search` with keywords from the complaint
|
|
71
|
+
- Or `/rules list` to see all rule files
|
|
72
|
+
- Or `soly_snippet` on `.agents/rules/*.md` files
|
|
73
|
+
|
|
74
|
+
If a rule already covers this but the agent ignored it:
|
|
75
|
+
- The rule may be too vague → propose **strengthening** it (edit, not new)
|
|
76
|
+
- The rule may not apply to the file's glob → propose adding globs
|
|
77
|
+
- The agent may have genuinely missed it → don't create a duplicate
|
|
78
|
+
|
|
79
|
+
If no rule covers it → proceed to Step 3.
|
|
80
|
+
|
|
81
|
+
### Step 3 — Categorize
|
|
82
|
+
|
|
83
|
+
Pick **one** category (auto-detect from the complaint content):
|
|
84
|
+
|
|
85
|
+
| Category | Path | Complaint is about |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| `coding` | `.agents/rules/coding/` | Code style, patterns, idioms, language conventions |
|
|
88
|
+
| `architecture` | `.agents/rules/architecture/` | Structure, layering, coupling, dependencies |
|
|
89
|
+
| `process` | `.agents/rules/process/` | Workflow, git hygiene, commit format, review process |
|
|
90
|
+
| `testing` | `.agents/rules/testing/` | Test structure, coverage, naming, fixtures |
|
|
91
|
+
| `naming` | `.agents/rules/naming/` | Identifier names, file names, conventions |
|
|
92
|
+
|
|
93
|
+
If unsure, default to `coding/` — it's the catch-all for code-level rules.
|
|
94
|
+
|
|
95
|
+
If the category directory doesn't exist, the `write` tool creates it
|
|
96
|
+
automatically (it creates parent directories).
|
|
97
|
+
|
|
98
|
+
### Step 4 — Draft the rule
|
|
99
|
+
|
|
100
|
+
Write the rule in **soly rule format**. Structure:
|
|
101
|
+
|
|
102
|
+
```markdown
|
|
103
|
+
---
|
|
104
|
+
description: "[one-line summary — what this rule enforces]"
|
|
105
|
+
globs: ["**/*.ts", "**/*.tsx"] # optional: file patterns this applies to
|
|
106
|
+
priority: high # high | medium | low (default medium)
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
# [Rule Name]
|
|
110
|
+
|
|
111
|
+
> **[One-line imperative.]** [Why it matters — the cost of violating.]
|
|
112
|
+
|
|
113
|
+
## The rule
|
|
114
|
+
|
|
115
|
+
[Concrete, actionable instruction. "Do X" not "consider X".
|
|
116
|
+
Reference the specific pattern. Include a threshold if applicable.]
|
|
117
|
+
|
|
118
|
+
[✓ Good example — code block showing the right way]
|
|
119
|
+
[✗ Bad example — code block showing what to avoid]
|
|
120
|
+
|
|
121
|
+
## Why
|
|
122
|
+
|
|
123
|
+
[The reasoning. Why does the user care? What goes wrong without this rule?
|
|
124
|
+
This section is what convinces the LLM to follow it, not just obey it.]
|
|
125
|
+
|
|
126
|
+
## Self-audit grep # optional — only for grep-checkable rules
|
|
127
|
+
|
|
128
|
+
[If the violation is detectable by grep/rg, include a command the agent
|
|
129
|
+
can run to self-check. Skip for behavioral/process rules.]
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
**Writing principles:**
|
|
133
|
+
- **Imperative mood:** "Use X" not "You should use X" or "X is preferred"
|
|
134
|
+
- **Concrete threshold:** "Functions under 50 lines" not "Keep functions short"
|
|
135
|
+
- **Show both sides:** ✓ good + ✗ bad code blocks make it unambiguous
|
|
136
|
+
- **Explain why:** the "## Why" section is what makes the LLM internalize it
|
|
137
|
+
- **No filler:** every line earns its place; rules eat system prompt budget
|
|
138
|
+
|
|
139
|
+
### Step 5 — Propose via ask_pro
|
|
140
|
+
|
|
141
|
+
**Do not write the file yet.** Show the draft to the user via `ask_pro`:
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
ask_pro({
|
|
145
|
+
questions: [{
|
|
146
|
+
header: "New rule",
|
|
147
|
+
question: "Create this rule to prevent the agent from repeating the issue?",
|
|
148
|
+
options: [
|
|
149
|
+
{
|
|
150
|
+
label: "Create rule",
|
|
151
|
+
description: "Write to .agents/rules/[category]/[name].md",
|
|
152
|
+
recommended: true,
|
|
153
|
+
preview: "<the full draft markdown in a fenced code block>"
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
label: "Edit existing",
|
|
157
|
+
description: "A similar rule exists — strengthen it instead",
|
|
158
|
+
preview: "<diff showing what to add>"
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
label: "Skip",
|
|
162
|
+
description: "Don't create a rule — just fix the code this time"
|
|
163
|
+
}
|
|
164
|
+
]
|
|
165
|
+
}]
|
|
166
|
+
})
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The `preview` field is key — it shows the user exactly what they're approving
|
|
170
|
+
in a side panel, formatted as code.
|
|
171
|
+
|
|
172
|
+
### Step 6 — Write (if confirmed)
|
|
173
|
+
|
|
174
|
+
If the user confirms "Create rule":
|
|
175
|
+
1. Use `write` to create the file at the chosen path
|
|
176
|
+
2. The hot-reload watcher picks it up automatically — no `/rules reload` needed
|
|
177
|
+
3. Emit a confirmation via `emit()` (if in soly context) or just tell the user
|
|
178
|
+
|
|
179
|
+
**File naming:** `kebab-case.md`, descriptive. `no-god-switches.md` not
|
|
180
|
+
`rule1.md` or `architecture-rule.md`.
|
|
181
|
+
|
|
182
|
+
## Anti-patterns (don't do these)
|
|
183
|
+
|
|
184
|
+
- ❌ **Don't create a rule for a one-off.** If the issue is specific to one
|
|
185
|
+
file and unlikely to recur, just fix the code. Rules are for patterns.
|
|
186
|
+
- ❌ **Don't create vague rules.** "Write clean code" is useless. "Functions
|
|
187
|
+
under 50 lines, extract helpers" is useful.
|
|
188
|
+
- ❌ **Don't duplicate linters.** If eslint/biome/prettier can enforce it,
|
|
189
|
+
point the user there instead. Use `/rulewizard` to decide.
|
|
190
|
+
- ❌ **Don't skip the "## Why" section.** Without reasoning, the LLM treats
|
|
191
|
+
the rule as arbitrary and may argue with it.
|
|
192
|
+
- ❌ **Don't auto-write without confirmation.** The user may want to rephrase,
|
|
193
|
+
recategorize, or decide it's not rule-worthy. Always ask_pro first.
|
|
194
|
+
- ❌ **Don't create multiple rules from one complaint.** One complaint → one
|
|
195
|
+
rule about the underlying pattern. Not five rules about symptoms.
|
|
196
|
+
|
|
197
|
+
## Integration with soly
|
|
198
|
+
|
|
199
|
+
- Rules created here are loaded by `loadAllRules()` on next session_start
|
|
200
|
+
(or immediately via hot-reload if soly's watcher is active)
|
|
201
|
+
- They appear in `/rules list` and the system prompt's `## ⚠️ MANDATORY` block
|
|
202
|
+
- Priority: project rules (`.agents/rules/`) = 3, local overrides
|
|
203
|
+
(`.agents/rules.local/`) = 5. Suggest `.agents/rules/` for team-shared
|
|
204
|
+
conventions, `.agents/rules.local/` for personal preferences
|
|
205
|
+
- Frontmatter `globs` makes the rule apply only to matching files (saves
|
|
206
|
+
prompt budget — a C# rule doesn't need to load for .ts files)
|
|
207
|
+
|
|
208
|
+
## Example session
|
|
209
|
+
|
|
210
|
+
**User:** "переделай god switch в customer-service.ts, мне не нравится"
|
|
211
|
+
|
|
212
|
+
**Agent (using this skill):**
|
|
213
|
+
|
|
214
|
+
1. Reads `customer-service.ts` — finds a 200-line switch on customer type
|
|
215
|
+
2. Searches existing rules — `/rules list` shows no switch-related rule
|
|
216
|
+
3. Categorizes → `architecture/` (structural issue, not just style)
|
|
217
|
+
4. Drafts:
|
|
218
|
+
|
|
219
|
+
```markdown
|
|
220
|
+
---
|
|
221
|
+
description: "Avoid giant switch statements — use dispatch maps or polymorphism"
|
|
222
|
+
globs: ["**/*.ts"]
|
|
223
|
+
priority: medium
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
# No God Switches
|
|
227
|
+
|
|
228
|
+
> **Switches with >5 cases must be refactored to a dispatch table.** Giant
|
|
229
|
+
> switches are unmaintainable and violate the open-closed principle.
|
|
230
|
+
|
|
231
|
+
## The rule
|
|
232
|
+
|
|
233
|
+
When a `switch` statement exceeds 5 cases (or a single case exceeds 20
|
|
234
|
+
lines), refactor to:
|
|
235
|
+
- A `Record<Case, Handler>` dispatch map (for simple dispatch)
|
|
236
|
+
- Polymorphism (when cases share behavior — subclasses + virtual method)
|
|
237
|
+
|
|
238
|
+
[✓ good: dispatch map example]
|
|
239
|
+
[✗ bad: 50-case switch example]
|
|
240
|
+
|
|
241
|
+
## Why
|
|
242
|
+
|
|
243
|
+
Giant switches grow linearly with new cases, are hard to test in isolation,
|
|
244
|
+
and make the agent tend to add "just one more case" instead of refactoring.
|
|
245
|
+
The user has to review the entire switch to verify one case is safe.
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
5. Shows via ask_pro with the full draft in `preview`
|
|
249
|
+
6. User confirms → writes to `.agents/rules/architecture/no-god-switches.md`
|
|
250
|
+
7. Hot-reload picks it up → rule is active for the next turn
|
package/visual/border.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// visual/border.ts — shared rounded-border renderer for modal panels
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// All soly modal panels (ListPanel, McpPanel, McpSetupPanel) draw bordered
|
|
6
|
+
// boxes. Before this module, each had its own helpers:
|
|
7
|
+
// - list-panel: frame(), headerLine(), footerLine(), ruleLine() (┌┐└┘)
|
|
8
|
+
// - mcp-panel: row(), emptyRow(), divider() (╭╮╰╯ + raw ANSI)
|
|
9
|
+
// - mcp-setup: padLine() (┌┐└┘)
|
|
10
|
+
//
|
|
11
|
+
// This module unifies them under one API with rounded corners (╭╮╰╯├┤│─).
|
|
12
|
+
// Each function takes a `styler` ((s: string) => string) so it works with
|
|
13
|
+
// any color system — pi's Theme.fg(), mcp-panel's fg(), or identity in tests.
|
|
14
|
+
//
|
|
15
|
+
// All width math uses pi-tui's visibleWidth/truncateToWidth so ANSI color
|
|
16
|
+
// codes inside `content` never break alignment (same approach as list-panel).
|
|
17
|
+
// =============================================================================
|
|
18
|
+
|
|
19
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
20
|
+
|
|
21
|
+
/** Colors the border characters. Identity (s => s) in tests. */
|
|
22
|
+
export type BorderStyler = (s: string) => string;
|
|
23
|
+
|
|
24
|
+
/** Top border with a centered title: `╭── title ──╮`. */
|
|
25
|
+
export function borderTop(title: string, innerW: number, styler: BorderStyler, titleStyler?: BorderStyler): string {
|
|
26
|
+
const ts = titleStyler ?? styler;
|
|
27
|
+
const titleW = visibleWidth(title);
|
|
28
|
+
const dashTotal = Math.max(0, innerW - titleW);
|
|
29
|
+
const leftDash = Math.floor(dashTotal / 2);
|
|
30
|
+
const rightDash = dashTotal - leftDash;
|
|
31
|
+
return styler("╭" + "─".repeat(leftDash)) + ts(title) + styler("─".repeat(rightDash) + "╮");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Bottom border: `╰──...──╯`. */
|
|
35
|
+
export function borderBottom(innerW: number, styler: BorderStyler): string {
|
|
36
|
+
return styler("╰" + "─".repeat(innerW) + "╯");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Mid-panel divider: `├──...──┤`. */
|
|
40
|
+
export function borderDivider(innerW: number, styler: BorderStyler): string {
|
|
41
|
+
return styler("├" + "─".repeat(innerW) + "┤");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Labelled divider inside the panel: `├── label ──┤`. */
|
|
45
|
+
export function borderLabelledDivider(label: string, innerW: number, styler: BorderStyler, labelStyler?: BorderStyler): string {
|
|
46
|
+
const ls = labelStyler ?? styler;
|
|
47
|
+
const head = `─ ${label} `;
|
|
48
|
+
const dashN = Math.max(0, innerW - visibleWidth(head) - 1);
|
|
49
|
+
return styler("├ ") + ls(head) + styler("─".repeat(dashN) + "┤");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Content row: `│ content │` (truncated with `…` if too wide, padded if short). */
|
|
53
|
+
export function borderRow(content: string, innerW: number, styler: BorderStyler): string {
|
|
54
|
+
const truncated = visibleWidth(content) > innerW
|
|
55
|
+
? truncateToWidth(content, innerW, "…", true)
|
|
56
|
+
: content;
|
|
57
|
+
const pad = Math.max(0, innerW - visibleWidth(truncated));
|
|
58
|
+
return styler("│ ") + truncated + " ".repeat(pad) + styler(" │");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Empty row: `│ │`. */
|
|
62
|
+
export function borderEmpty(innerW: number, styler: BorderStyler): string {
|
|
63
|
+
return styler("│") + " ".repeat(innerW) + styler("│");
|
|
64
|
+
}
|
package/visual/fuzzy.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// visual/fuzzy.ts — shared subsequence fuzzy match scorer
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Extracted from list-panel.ts (where it was duplicated in mcp-panel.ts).
|
|
6
|
+
// Pure function, no deps — trivially testable.
|
|
7
|
+
//
|
|
8
|
+
// Scoring:
|
|
9
|
+
// - exact substring match: 100 + (queryLen / textLen) — highest
|
|
10
|
+
// - in-order subsequence: 1 — match but not contiguous
|
|
11
|
+
// - no match: 0
|
|
12
|
+
// =============================================================================
|
|
13
|
+
|
|
14
|
+
/** Subsequence fuzzy match: substring scores highest, then in-order chars.
|
|
15
|
+
* Returns 0 for no match, >0 for a match. */
|
|
16
|
+
export function fuzzyScore(query: string, text: string): number {
|
|
17
|
+
const q = query.toLowerCase();
|
|
18
|
+
const t = text.toLowerCase();
|
|
19
|
+
if (!q) return 1;
|
|
20
|
+
if (t.includes(q)) return 100 + q.length / t.length;
|
|
21
|
+
let qi = 0;
|
|
22
|
+
for (let i = 0; i < t.length && qi < q.length; i++) {
|
|
23
|
+
if (t[i] === q[qi]) qi++;
|
|
24
|
+
}
|
|
25
|
+
return qi === q.length ? 1 : 0;
|
|
26
|
+
}
|
package/visual/list-panel.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@ea
|
|
|
16
16
|
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
17
17
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
18
18
|
import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "./panel-keys.ts";
|
|
19
|
+
import { fuzzyScore } from "./fuzzy.ts";
|
|
19
20
|
// (PanelKeybindings re-exported by re-export side below.)
|
|
20
21
|
|
|
21
22
|
/** One row in the panel. `body` is shown in the preview pane when selected. */
|
|
@@ -60,15 +61,7 @@ const MAX_ROWS = 12; // list window height (render() has no viewport height)
|
|
|
60
61
|
const PREVIEW_LINES = 4;
|
|
61
62
|
|
|
62
63
|
/** Subsequence fuzzy match: substring scores highest, then in-order chars. */
|
|
63
|
-
|
|
64
|
-
const q = query.toLowerCase();
|
|
65
|
-
const t = text.toLowerCase();
|
|
66
|
-
if (!q) return 1;
|
|
67
|
-
if (t.includes(q)) return 100 + q.length / t.length;
|
|
68
|
-
let qi = 0;
|
|
69
|
-
for (let i = 0; i < t.length && qi < q.length; i++) if (t[i] === q[qi]) qi++;
|
|
70
|
-
return qi === q.length ? 1 : 0;
|
|
71
|
-
}
|
|
64
|
+
// fuzzyScore moved to ./fuzzy.ts — imported above.
|
|
72
65
|
|
|
73
66
|
/** Index into `flatRows` (mixed item + group header rows). */
|
|
74
67
|
type RowIndex = number;
|
|
@@ -253,7 +246,7 @@ export class ListPanel implements Component {
|
|
|
253
246
|
const left = ` ${this.p.title} `;
|
|
254
247
|
const right = this.p.headerRight ? ` ${this.p.headerRight} ` : "";
|
|
255
248
|
const fillN = Math.max(1, inner + 2 - visibleWidth(left) - visibleWidth(right));
|
|
256
|
-
return dim("
|
|
249
|
+
return dim("╭") + muted(left) + dim("─".repeat(fillN)) + muted(right) + dim("╮");
|
|
257
250
|
}
|
|
258
251
|
|
|
259
252
|
private searchLine(inner: number, dim: (s: string) => string, muted: (s: string) => string): string {
|
|
@@ -289,6 +282,6 @@ export class ListPanel implements Component {
|
|
|
289
282
|
const open = this.p.onSelect ? "⏎ open · " : "";
|
|
290
283
|
const hint = ` ↑↓ move · ${open}/ search${acts ? " · " + acts : ""} · esc `;
|
|
291
284
|
const fillN = Math.max(1, inner + 2 - visibleWidth(hint));
|
|
292
|
-
return dim("
|
|
285
|
+
return dim("╰") + dim(hint) + dim("─".repeat(fillN)) + dim("╯");
|
|
293
286
|
}
|
|
294
287
|
}
|