pi-soly 2.1.5 → 2.2.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/built-in-rules/release-discipline.md +105 -0
- package/commands/_helpers.ts +87 -0
- package/commands/artifacts.ts +86 -0
- package/commands/docs.ts +74 -0
- package/commands/rules.ts +187 -0
- package/commands/rulewizard.ts +43 -0
- package/commands/soly.ts +560 -0
- package/commands/why.ts +106 -0
- package/commands.ts +36 -1148
- package/index.ts +9 -0
- package/mcp/mcp-panel.ts +1 -1
- package/mcp/mcp-setup-panel.ts +1 -1
- package/package.json +2 -1
- package/visual/list-panel.ts +112 -25
- package/workflows/settings-ui.ts +466 -0
- /package/{mcp → visual}/panel-keys.ts +0 -0
package/index.ts
CHANGED
|
@@ -375,6 +375,15 @@ export default function solyExtension(pi: ExtensionAPI) {
|
|
|
375
375
|
refreshState: () => refreshState(),
|
|
376
376
|
updateStatus: (ui) => updateStatus(ui),
|
|
377
377
|
getConfig: getActiveConfig,
|
|
378
|
+
reloadConfig: () => {
|
|
379
|
+
if (!sessionCwd) return;
|
|
380
|
+
const cfgResult = loadConfig(sessionCwd);
|
|
381
|
+
activeConfig = cfgResult.config;
|
|
382
|
+
// Re-warn about config drift (mirrors the warnings at session_start).
|
|
383
|
+
for (const w of cfgResult.warnings) {
|
|
384
|
+
pi.sendUserMessage?.(`soly: ${w}`, { deliverAs: "followUp" });
|
|
385
|
+
}
|
|
386
|
+
},
|
|
378
387
|
getIntentDocs: () => intentDocs,
|
|
379
388
|
});
|
|
380
389
|
|
package/mcp/mcp-panel.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
-
import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "
|
|
2
|
+
import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "../visual/panel-keys.ts";
|
|
3
3
|
import { isToolExcluded } from "./types.ts";
|
|
4
4
|
import type { McpConfig, McpPanelCallbacks, McpPanelResult, ServerProvenance } from "./types.ts";
|
|
5
5
|
import { resourceNameToToolName } from "./resource-tools.ts";
|
package/mcp/mcp-setup-panel.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
-
import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "
|
|
2
|
+
import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "../visual/panel-keys.ts";
|
|
3
3
|
import type { ImportKind } from "./types.ts";
|
|
4
4
|
import type { ConfigWritePreview, McpDiscoverySummary } from "./config.ts";
|
|
5
5
|
import type { McpOnboardingState } from "./onboarding-state.ts";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-soly",
|
|
3
|
-
"version": "2.1
|
|
3
|
+
"version": "2.2.1",
|
|
4
4
|
"description": "Project management + workflow framework for pi-coding-agent. Plans, state, MANDATORY rules, self-review, multi-question picker, native footer + welcome chrome — one npm install, zero config. The LLM drives the workflow inline via the soly_workflow tool — no external subagent plugin.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"files": [
|
|
31
31
|
"README.md",
|
|
32
32
|
"commands.ts",
|
|
33
|
+
"commands",
|
|
33
34
|
"config.ts",
|
|
34
35
|
"context-manager.ts",
|
|
35
36
|
"core.ts",
|
package/visual/list-panel.ts
CHANGED
|
@@ -15,26 +15,42 @@
|
|
|
15
15
|
import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
16
16
|
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
17
17
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
18
|
-
import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "
|
|
18
|
+
import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "./panel-keys.ts";
|
|
19
|
+
// (PanelKeybindings re-exported by re-export side below.)
|
|
19
20
|
|
|
20
21
|
/** One row in the panel. `body` is shown in the preview pane when selected. */
|
|
21
22
|
export type ListItem = { id: string; marker: string; label: string; meta?: string; body?: string };
|
|
22
23
|
|
|
24
|
+
/** A logical group within the panel (rendered as a labelled separator). */
|
|
25
|
+
export type ListGroup = {
|
|
26
|
+
id: string;
|
|
27
|
+
/** Single glyph + short title shown on the separator row (e.g. "📊 Status"). */
|
|
28
|
+
title: string;
|
|
29
|
+
icon: string;
|
|
30
|
+
items: ListItem[];
|
|
31
|
+
};
|
|
32
|
+
|
|
23
33
|
/** A key-triggered action over the selected item (e.g. enable/disable/reload). */
|
|
24
34
|
export type ListAction = { key: string; hint: string; run: (item: ListItem) => void };
|
|
25
35
|
|
|
36
|
+
// Re-export PanelKeybindings so consumers don't need to know about the
|
|
37
|
+
// internal ./panel-keys.ts module.
|
|
38
|
+
export type { PanelKeybindings, PanelKeys };
|
|
39
|
+
|
|
26
40
|
export type ListPanelProps = {
|
|
27
41
|
tui: TUI;
|
|
28
42
|
theme: Theme;
|
|
29
43
|
keybindings?: PanelKeybindings;
|
|
44
|
+
// (Above prop is the public re-export anchor.)
|
|
30
45
|
done: () => void;
|
|
31
46
|
title: string;
|
|
32
47
|
/** Right-aligned header text (e.g. counts / token budget). */
|
|
33
48
|
headerRight?: string;
|
|
34
|
-
|
|
49
|
+
/** Items, grouped. A single group is fine. Cursor skips group headers. */
|
|
50
|
+
groups: ListGroup[];
|
|
35
51
|
actions?: ListAction[];
|
|
36
52
|
/** Re-read items after an action mutates state. */
|
|
37
|
-
refresh?: () =>
|
|
53
|
+
refresh?: () => ListGroup[];
|
|
38
54
|
/** Fired on Enter for the selected item; the panel then closes. When set,
|
|
39
55
|
* the footer shows an "⏎ open" hint. */
|
|
40
56
|
onSelect?: (item: ListItem) => void;
|
|
@@ -54,17 +70,20 @@ function fuzzyScore(query: string, text: string): number {
|
|
|
54
70
|
return qi === q.length ? 1 : 0;
|
|
55
71
|
}
|
|
56
72
|
|
|
73
|
+
/** Index into `flatRows` (mixed item + group header rows). */
|
|
74
|
+
type RowIndex = number;
|
|
75
|
+
|
|
57
76
|
export class ListPanel implements Component {
|
|
58
77
|
private readonly p: ListPanelProps;
|
|
59
78
|
private readonly keys: PanelKeys;
|
|
60
|
-
private
|
|
79
|
+
private groups: ListGroup[];
|
|
61
80
|
private query = "";
|
|
62
81
|
private searching = false;
|
|
63
|
-
private selected = 0;
|
|
82
|
+
private selected: RowIndex = 0;
|
|
64
83
|
|
|
65
84
|
constructor(props: ListPanelProps) {
|
|
66
85
|
this.p = props;
|
|
67
|
-
this.
|
|
86
|
+
this.groups = props.groups;
|
|
68
87
|
this.keys = createPanelKeys(props.keybindings);
|
|
69
88
|
}
|
|
70
89
|
|
|
@@ -72,22 +91,65 @@ export class ListPanel implements Component {
|
|
|
72
91
|
/* stateless cache */
|
|
73
92
|
}
|
|
74
93
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
.
|
|
94
|
+
/** Flatten groups into rows (items + header separators). Headers carry a
|
|
95
|
+
* `group` reference for rendering; cursor skips them. */
|
|
96
|
+
private flatRows(): Array<{ kind: "item"; item: ListItem; group: ListGroup } | { kind: "header"; group: ListGroup }> {
|
|
97
|
+
const out: Array<{ kind: "item"; item: ListItem; group: ListGroup } | { kind: "header"; group: ListGroup }> = [];
|
|
98
|
+
for (const g of this.groups) {
|
|
99
|
+
// Skip groups that have no items — no point showing an empty separator.
|
|
100
|
+
if (g.items.length === 0) continue;
|
|
101
|
+
out.push({ kind: "header", group: g });
|
|
102
|
+
for (const item of g.items) out.push({ kind: "item", item, group: g });
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
82
105
|
}
|
|
83
106
|
|
|
84
|
-
|
|
85
|
-
|
|
107
|
+
/** Apply fuzzy filter; when searching, group headers are still shown but
|
|
108
|
+
* only groups that contain at least one matching item survive, and
|
|
109
|
+
* within those groups only matching items are returned. */
|
|
110
|
+
private filteredRows(): Array<{ kind: "item"; item: ListItem; group: ListGroup } | { kind: "header"; group: ListGroup }> {
|
|
111
|
+
if (!this.query) return this.flatRows();
|
|
112
|
+
const q = this.query.toLowerCase();
|
|
113
|
+
const matchedByGroup = new Map<ListGroup, Set<ListItem>>();
|
|
114
|
+
for (const g of this.groups) {
|
|
115
|
+
const matched = new Set<ListItem>();
|
|
116
|
+
for (const it of g.items) {
|
|
117
|
+
if (fuzzyScore(q, `${it.label} ${it.meta ?? ""}`) > 0) matched.add(it);
|
|
118
|
+
}
|
|
119
|
+
if (matched.size > 0) matchedByGroup.set(g, matched);
|
|
120
|
+
}
|
|
121
|
+
const out: Array<{ kind: "item"; item: ListItem; group: ListGroup } | { kind: "header"; group: ListGroup }> = [];
|
|
122
|
+
for (const g of this.groups) {
|
|
123
|
+
const matched = matchedByGroup.get(g);
|
|
124
|
+
if (!matched) continue;
|
|
125
|
+
out.push({ kind: "header", group: g });
|
|
126
|
+
for (const it of g.items) if (matched.has(it)) out.push({ kind: "item", item: it, group: g });
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private clamp(list: { kind: "item" | "header" }[]): void {
|
|
132
|
+
if (list.length === 0) {
|
|
133
|
+
this.selected = 0;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// First clamp into valid range.
|
|
137
|
+
if (this.selected >= list.length) this.selected = list.length - 1;
|
|
86
138
|
if (this.selected < 0) this.selected = 0;
|
|
139
|
+
// Then walk to the nearest non-header row — prefer forward (visual
|
|
140
|
+
// scan direction), fall back to backward.
|
|
141
|
+
if (list[this.selected]?.kind === "header") {
|
|
142
|
+
for (let i = this.selected; i < list.length; i++) {
|
|
143
|
+
if (list[i]?.kind === "item") { this.selected = i; return; }
|
|
144
|
+
}
|
|
145
|
+
for (let i = this.selected; i >= 0; i--) {
|
|
146
|
+
if (list[i]?.kind === "item") { this.selected = i; return; }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
87
149
|
}
|
|
88
150
|
|
|
89
151
|
handleInput(data: string): void {
|
|
90
|
-
const list = this.
|
|
152
|
+
const list = this.filteredRows();
|
|
91
153
|
this.clamp(list);
|
|
92
154
|
|
|
93
155
|
if (this.searching) {
|
|
@@ -109,23 +171,28 @@ export class ListPanel implements Component {
|
|
|
109
171
|
}
|
|
110
172
|
if (this.keys.selectUp(data)) {
|
|
111
173
|
this.selected = Math.max(0, this.selected - 1);
|
|
174
|
+
// Skip group headers when moving up.
|
|
175
|
+
while (this.selected > 0 && list[this.selected]?.kind === "header") this.selected--;
|
|
112
176
|
} else if (this.keys.selectDown(data)) {
|
|
113
177
|
this.selected = Math.min(list.length - 1, this.selected + 1);
|
|
178
|
+
// Skip group headers when moving down.
|
|
179
|
+
while (this.selected < list.length - 1 && list[this.selected]?.kind === "header") this.selected++;
|
|
114
180
|
} else if (data === "/") {
|
|
115
181
|
this.searching = true;
|
|
116
182
|
} else if (matchesKey(data, "return")) {
|
|
117
183
|
const current = list[this.selected];
|
|
118
|
-
|
|
119
|
-
|
|
184
|
+
// Headers are not selectable — ignore Enter on them.
|
|
185
|
+
if (current && current.kind === "item" && this.p.onSelect) {
|
|
186
|
+
this.p.onSelect(current.item);
|
|
120
187
|
this.p.done();
|
|
121
188
|
return;
|
|
122
189
|
}
|
|
123
190
|
} else {
|
|
124
191
|
const action = this.p.actions?.find((a) => a.key === data);
|
|
125
192
|
const current = list[this.selected];
|
|
126
|
-
if (action && current) {
|
|
127
|
-
action.run(current);
|
|
128
|
-
if (this.p.refresh) this.
|
|
193
|
+
if (action && current && current.kind === "item") {
|
|
194
|
+
action.run(current.item);
|
|
195
|
+
if (this.p.refresh) this.groups = this.p.refresh();
|
|
129
196
|
}
|
|
130
197
|
}
|
|
131
198
|
this.p.tui.requestRender();
|
|
@@ -136,7 +203,7 @@ export class ListPanel implements Component {
|
|
|
136
203
|
const dim = (s: string) => theme.fg("dim", s);
|
|
137
204
|
const muted = (s: string) => theme.fg("muted", s);
|
|
138
205
|
const inner = Math.max(20, width - 4);
|
|
139
|
-
const list = this.
|
|
206
|
+
const list = this.filteredRows();
|
|
140
207
|
this.clamp(list);
|
|
141
208
|
|
|
142
209
|
const out: string[] = [];
|
|
@@ -144,19 +211,39 @@ export class ListPanel implements Component {
|
|
|
144
211
|
out.push(this.searchLine(inner, dim, muted));
|
|
145
212
|
out.push(this.frame("", inner, dim));
|
|
146
213
|
|
|
147
|
-
// Windowed
|
|
214
|
+
// Windowed view around the selection.
|
|
215
|
+
const selectedRow = list[this.selected];
|
|
148
216
|
const start = Math.max(0, Math.min(this.selected - Math.floor(MAX_ROWS / 2), Math.max(0, list.length - MAX_ROWS)));
|
|
149
217
|
const window = list.slice(start, start + MAX_ROWS);
|
|
150
218
|
if (window.length === 0) out.push(this.frame(dim(" (no matches)"), inner, dim));
|
|
151
|
-
for (let i = 0; i < window.length; i++)
|
|
219
|
+
for (let i = 0; i < window.length; i++) {
|
|
220
|
+
const r = window[i];
|
|
221
|
+
if (!r) continue;
|
|
222
|
+
const absoluteIdx = start + i;
|
|
223
|
+
if (r.kind === "header") {
|
|
224
|
+
out.push(this.groupHeaderLine(r.group, inner, dim, muted));
|
|
225
|
+
} else {
|
|
226
|
+
const sel = absoluteIdx === this.selected && selectedRow?.kind === "item";
|
|
227
|
+
out.push(this.rowLine(r.item, sel, inner, dim, muted));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
152
230
|
|
|
231
|
+
// Preview shows the selected item only — headers have no preview.
|
|
153
232
|
out.push(this.ruleLine("preview", inner, dim));
|
|
154
|
-
|
|
233
|
+
const previewItem = selectedRow?.kind === "item" ? selectedRow.item : undefined;
|
|
234
|
+
for (const line of this.previewLines(previewItem, inner)) out.push(this.frame(" " + muted(line), inner, dim));
|
|
155
235
|
|
|
156
236
|
out.push(this.footerLine(inner, dim));
|
|
157
237
|
return out;
|
|
158
238
|
}
|
|
159
239
|
|
|
240
|
+
/** Full-width separator row: `📊 Status ─────────`. */
|
|
241
|
+
private groupHeaderLine(g: ListGroup, inner: number, dim: (s: string) => string, muted: (s: string) => string): string {
|
|
242
|
+
const head = ` ${dim(g.icon)} ${muted(g.title)} `;
|
|
243
|
+
const dashes = Math.max(0, inner - visibleWidth(head));
|
|
244
|
+
return dim(`│${head}${"─".repeat(dashes)} │`);
|
|
245
|
+
}
|
|
246
|
+
|
|
160
247
|
private frame(content: string, inner: number, dim: (s: string) => string): string {
|
|
161
248
|
const pad = Math.max(0, inner - visibleWidth(content));
|
|
162
249
|
return dim("│ ") + content + " ".repeat(pad) + dim(" │");
|