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/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 "./panel-keys.ts";
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";
@@ -1,5 +1,5 @@
1
1
  import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
- import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "./panel-keys.ts";
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.5",
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",
@@ -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 "../mcp/panel-keys.ts";
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
- items: ListItem[];
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?: () => ListItem[];
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 items: ListItem[];
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.items = props.items;
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
- private filtered(): ListItem[] {
76
- if (!this.query) return this.items;
77
- return this.items
78
- .map((it) => ({ it, s: fuzzyScore(this.query, `${it.label} ${it.meta ?? ""}`) }))
79
- .filter((x) => x.s > 0)
80
- .sort((a, b) => b.s - a.s)
81
- .map((x) => x.it);
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
- private clamp(list: ListItem[]): void {
85
- if (this.selected >= list.length) this.selected = Math.max(0, list.length - 1);
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.filtered();
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
- if (this.p.onSelect && current) {
119
- this.p.onSelect(current);
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.items = this.p.refresh();
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.filtered();
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 list around the selection.
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++) out.push(this.rowLine(window[i] as ListItem, start + i === this.selected, inner, dim, muted));
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
- for (const line of this.previewLines(list[this.selected], inner)) out.push(this.frame(" " + muted(line), inner, dim));
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(" │");