@aeondave/pi-persona 1.17.0 → 1.17.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.
@@ -0,0 +1,322 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ Text,
4
+ truncateToWidth,
5
+ visibleWidth,
6
+ type Component,
7
+ type KeybindingsManager,
8
+ type TUI,
9
+ } from "@earendil-works/pi-tui";
10
+
11
+ import { compactInlineText, sanitizeTerminalText } from "./presentation.ts";
12
+ import type { SettingId, SettingKind } from "../core/settings.ts";
13
+
14
+ export interface SettingsPanelRow {
15
+ id: SettingId;
16
+ group: string;
17
+ label: string;
18
+ description: string;
19
+ kind: SettingKind;
20
+ choices?: readonly string[];
21
+ activeValue: unknown;
22
+ savedValue: unknown;
23
+ hasSavedValue: boolean;
24
+ source: "environment" | "saved" | "default";
25
+ locked: boolean;
26
+ pendingReload?: boolean;
27
+ applyWhen: string;
28
+ min?: number;
29
+ max?: number;
30
+ }
31
+
32
+ export interface SettingsPanelData {
33
+ file: string;
34
+ lastPersona: string | null;
35
+ rows: readonly SettingsPanelRow[];
36
+ notice?: string;
37
+ initialQuery?: string;
38
+ initialSelectedId?: SettingId;
39
+ }
40
+
41
+ export type SettingsPanelAction =
42
+ | { type: "edit"; id: SettingId }
43
+ | { type: "reset"; id: SettingId }
44
+ | { type: "models" }
45
+ | { type: "diagnostics" }
46
+ | { type: "close" };
47
+
48
+ /** Case-insensitive search across the registry's user-facing metadata; source order is retained. */
49
+ export function filterSettingsRows(rows: readonly SettingsPanelRow[], query: string): SettingsPanelRow[] {
50
+ const needle = sanitizeTerminalText(query).trim().toLocaleLowerCase();
51
+ if (!needle) return [...rows];
52
+ return rows.filter((row) => {
53
+ const haystack = [row.id, row.group, row.label, row.description, row.applyWhen]
54
+ .concat(row.choices ?? [])
55
+ .join(" ")
56
+ .toLocaleLowerCase();
57
+ return haystack.includes(needle);
58
+ });
59
+ }
60
+
61
+ /** Scroll offset that keeps the selected setting inside a bounded list viewport. */
62
+ export function visibleSettingsWindow(selected: number, size: number, total: number, previous: number): number {
63
+ const count = Math.max(0, Math.floor(total));
64
+ const page = Math.max(1, Math.floor(size));
65
+ if (count <= page) return 0;
66
+ const index = Math.max(0, Math.min(Math.floor(selected), count - 1));
67
+ let scroll = Math.max(0, Math.floor(previous));
68
+ if (index < scroll) scroll = index;
69
+ else if (index >= scroll + page) scroll = index - page + 1;
70
+ return Math.max(0, Math.min(scroll, count - page));
71
+ }
72
+
73
+ /** Human-readable, one-line values for settings rows and details. */
74
+ export function formatSettingValue(value: unknown): string {
75
+ if (value === undefined) return "(unset)";
76
+ if (value === null) return "(unset)";
77
+ if (typeof value === "boolean") return value ? "On" : "Off";
78
+ if (typeof value === "number") return Number.isFinite(value) ? String(value) : "(invalid number)";
79
+ if (Array.isArray(value)) {
80
+ if (value.length === 0) return "(empty list)";
81
+ return value.map((item) => compactInlineText(String(item), { maxChars: 160 })).join(", ");
82
+ }
83
+ if (typeof value === "string") return compactInlineText(value, { maxChars: 160 }) || "(empty)";
84
+ return compactInlineText(String(value), { maxChars: 160 }) || "(empty)";
85
+ }
86
+
87
+ /** One fully composed list entry, clipped by terminal columns rather than JS string length. */
88
+ export function composeSettingsRow(row: SettingsPanelRow, selected: boolean, maxColumns: number, theme: Theme): string {
89
+ const width = Math.max(1, Math.floor(maxColumns));
90
+ const marker = selected ? theme.fg("accent", "▸ ") : " ";
91
+ const group = theme.fg("dim", `[${compactInlineText(row.group, { maxChars: 32 })}] `);
92
+ const label = compactInlineText(row.label, { maxChars: Math.max(16, Math.floor(width * 0.55)) });
93
+ const value = compactInlineText(formatSettingValue(row.activeValue), { maxChars: Math.max(16, Math.floor(width * 0.44)) });
94
+ const source = row.locked ? theme.fg("warning", " env locked") : row.hasSavedValue ? theme.fg("dim", " saved") : "";
95
+ const valueText = theme.fg("dim", ` ${value}`);
96
+ return fitLine(`${marker}${group}${label}${valueText}${source}`, width);
97
+ }
98
+
99
+ function fitLine(line: string, width: number): string {
100
+ // Callers sanitize all registry, path, and stored-value text before applying theme roles. Keep
101
+ // those trusted theme escapes intact so the host palette survives truncation and layout.
102
+ // Do not normalize ordinary spaces: columnLines uses them to keep the detail column aligned.
103
+ const safe = line.replace(/[\r\n]+/g, " ");
104
+ if (visibleWidth(safe) <= width) return safe;
105
+ return truncateToWidth(safe, width, "…");
106
+ }
107
+
108
+ function wrappedLines(text: string, width: number): string[] {
109
+ const safe = sanitizeTerminalText(text).replace(/\r\n?/g, "\n");
110
+ const rendered = new Text(safe, 0, 0).render(Math.max(1, width));
111
+ return rendered.map((line) => fitLine(line, Math.max(1, width)));
112
+ }
113
+
114
+ function detailLines(row: SettingsPanelRow, width: number, limit: number, theme: Theme): string[] {
115
+ const safeWidth = Math.max(1, width);
116
+ const lines: string[] = [theme.fg("accent", theme.bold(compactInlineText(row.label, { maxChars: 160 })))];
117
+ lines.push(theme.fg("dim", `Group: ${compactInlineText(row.group, { maxChars: 100 })}`));
118
+ const fixedLines = row.pendingReload ? 7 : 6;
119
+ const description = wrappedLines(row.description, safeWidth).slice(0, Math.max(1, Math.min(3, limit - fixedLines)));
120
+ lines.push(...description);
121
+ lines.push(`Active now: ${formatSettingValue(row.activeValue)}`);
122
+ lines.push(`Saved override: ${row.hasSavedValue ? formatSettingValue(row.savedValue) : "none"}`);
123
+ lines.push(`Source: ${row.locked ? "environment · locked" : row.source}`);
124
+ if (row.pendingReload) lines.push("Status: saved value is pending /reload");
125
+ lines.push(`Applies: ${compactInlineText(row.applyWhen, { maxChars: 120 })}`);
126
+ if (row.locked && row.choices?.length) lines.push("Environment value must be unset to edit.");
127
+ return lines.slice(0, Math.max(1, limit)).map((line) => fitLine(line, safeWidth));
128
+ }
129
+
130
+ function listLines(
131
+ rows: readonly SettingsPanelRow[],
132
+ selectedIndex: number,
133
+ height: number,
134
+ width: number,
135
+ previousScroll: number,
136
+ theme: Theme,
137
+ ): { lines: string[]; scroll: number } {
138
+ const limit = Math.max(1, height);
139
+ if (rows.length === 0) return { lines: [theme.fg("dim", "No settings match this filter.")], scroll: 0 };
140
+ const needsMarkers = rows.length > limit;
141
+ const itemCount = Math.max(1, limit - (needsMarkers ? 2 : 0));
142
+ const scroll = visibleSettingsWindow(selectedIndex, itemCount, rows.length, previousScroll);
143
+ const end = Math.min(rows.length, scroll + itemCount);
144
+ const lines: string[] = [];
145
+ if (scroll > 0) lines.push(theme.fg("dim", `↑ ${scroll} settings above`));
146
+ for (let index = scroll; index < end; index++) {
147
+ const row = rows[index];
148
+ if (row) lines.push(composeSettingsRow(row, index === selectedIndex, width, theme));
149
+ }
150
+ if (end < rows.length) lines.push(theme.fg("dim", `↓ ${rows.length - end} settings below`));
151
+ return { lines: lines.slice(0, limit).map((line) => fitLine(line, width)), scroll };
152
+ }
153
+
154
+ function columnLines(left: readonly string[], right: readonly string[], leftWidth: number, rightWidth: number, width: number): string[] {
155
+ const count = Math.max(left.length, right.length);
156
+ const gap = " ";
157
+ const lines: string[] = [];
158
+ for (let index = 0; index < count; index++) {
159
+ const leftLine = fitLine(left[index] ?? "", leftWidth);
160
+ const pad = Math.max(0, leftWidth - visibleWidth(leftLine));
161
+ const rightLine = fitLine(right[index] ?? "", rightWidth);
162
+ lines.push(fitLine(`${leftLine}${" ".repeat(pad)}${gap}${rightLine}`, width));
163
+ }
164
+ return lines;
165
+ }
166
+
167
+ function isPrintable(key: string): boolean {
168
+ if (key.length !== 1) return false;
169
+ const code = key.charCodeAt(0);
170
+ return code >= 0x20 && code !== 0x7f;
171
+ }
172
+
173
+ /** Full-page settings workbench. Dialogs are returned as actions so command handlers can use Pi's native prompts. */
174
+ export class PersonaSettingsPanel implements Component {
175
+ private query: string;
176
+ private searchMode = false;
177
+ private selectedIndex = 0;
178
+ private scroll = 0;
179
+ private lastWidth = 80;
180
+ private readonly tui: TUI;
181
+ private readonly theme: Theme;
182
+ private readonly keybindings: KeybindingsManager;
183
+ private readonly data: SettingsPanelData;
184
+ private readonly onAction: (action: SettingsPanelAction) => void;
185
+
186
+ constructor(
187
+ tui: TUI,
188
+ theme: Theme,
189
+ keybindings: KeybindingsManager,
190
+ data: SettingsPanelData,
191
+ onAction: (action: SettingsPanelAction) => void,
192
+ ) {
193
+ this.tui = tui;
194
+ this.theme = theme;
195
+ this.keybindings = keybindings;
196
+ this.data = data;
197
+ this.onAction = onAction;
198
+ this.query = data.initialQuery ?? "";
199
+ const initialRows = filterSettingsRows(data.rows, this.query);
200
+ const initialIndex = data.initialSelectedId
201
+ ? initialRows.findIndex((row) => row.id === data.initialSelectedId)
202
+ : -1;
203
+ this.selectedIndex = initialIndex >= 0 ? initialIndex : 0;
204
+ }
205
+
206
+ get searchQuery(): string {
207
+ return this.query;
208
+ }
209
+
210
+ get selectedId(): SettingId | undefined {
211
+ return filterSettingsRows(this.data.rows, this.query)[this.selectedIndex]?.id;
212
+ }
213
+
214
+ invalidate(): void {
215
+ // Rendering is derived entirely from the small immutable snapshot passed by the command.
216
+ }
217
+
218
+ render(width: number): string[] {
219
+ const pageWidth = Math.max(1, Math.floor(width));
220
+ this.lastWidth = pageWidth;
221
+ const rows = filterSettingsRows(this.data.rows, this.query);
222
+ if (this.selectedIndex >= rows.length) this.selectedIndex = Math.max(0, rows.length - 1);
223
+ const selected = rows[this.selectedIndex];
224
+ const terminalRows = Math.max(8, Math.floor(this.tui.terminal.rows || 24));
225
+ const title = this.theme.fg("accent", this.theme.bold("Persona settings"));
226
+ const file = fitLine(this.theme.fg("dim", `Configuration file: ${compactInlineText(this.data.file, { maxChars: 240 })}`), pageWidth);
227
+ const remembered = fitLine(this.theme.fg("dim", `Remembered persona: ${this.data.lastPersona ? compactInlineText(this.data.lastPersona, { maxChars: 120 }) : "none"}`), pageWidth);
228
+ const filter = this.searchMode
229
+ ? this.theme.fg("accent", `Filter (type, backspace, enter): ${compactInlineText(this.query, { maxChars: 120 })}▌`)
230
+ : this.query
231
+ ? this.theme.fg("dim", `Filter: ${compactInlineText(this.query, { maxChars: 120 })}`)
232
+ : this.theme.fg("dim", "Filter: press / to search");
233
+ const header = [fitLine(title, pageWidth), file, remembered, fitLine(filter, pageWidth), ""];
234
+ const footer = [
235
+ ...(this.data.notice ? [fitLine(this.theme.fg("warning", compactInlineText(this.data.notice, { maxChars: 200 })), pageWidth)] : []),
236
+ ...(pageWidth < 76
237
+ ? ["↑↓ move enter edit r reset", "/ filter m models d info esc close"]
238
+ : ["↑↓ move enter edit r reset / filter m models d diagnostics esc close"])
239
+ .map((hint) => fitLine(this.theme.fg("dim", hint), pageWidth)),
240
+ ];
241
+ const bodyHeight = Math.max(1, terminalRows - header.length - footer.length);
242
+ let body: string[];
243
+ if (!selected) {
244
+ body = [this.theme.fg("dim", "No settings match this filter.")];
245
+ } else if (pageWidth >= 80) {
246
+ const leftWidth = Math.max(24, Math.floor((pageWidth - 2) * 0.42));
247
+ const rightWidth = Math.max(1, pageWidth - leftWidth - 2);
248
+ const listing = listLines(rows, this.selectedIndex, bodyHeight, leftWidth, this.scroll, this.theme);
249
+ this.scroll = listing.scroll;
250
+ const details = detailLines(selected, rightWidth, bodyHeight, this.theme);
251
+ body = columnLines(listing.lines, details, leftWidth, rightWidth, pageWidth);
252
+ } else {
253
+ const detailsBudget = Math.min(Math.max(3, Math.floor(bodyHeight * 0.55)), Math.max(1, bodyHeight - 1));
254
+ const listBudget = Math.max(1, bodyHeight - detailsBudget - 1);
255
+ const listing = listLines(rows, this.selectedIndex, listBudget, pageWidth, this.scroll, this.theme);
256
+ this.scroll = listing.scroll;
257
+ const details = detailLines(selected, pageWidth, Math.max(1, bodyHeight - listing.lines.length - 1), this.theme);
258
+ body = [...listing.lines, "", ...details].slice(0, bodyHeight);
259
+ }
260
+ const output = [...header, ...body, ...footer].slice(0, terminalRows);
261
+ return output.map((line) => fitLine(line, pageWidth));
262
+ }
263
+
264
+ handleInput(key: string): void {
265
+ const matches = (binding: "tui.select.up" | "tui.select.down" | "tui.select.confirm" | "tui.select.cancel"): boolean =>
266
+ this.keybindings.matches(key, binding);
267
+ const rows = filterSettingsRows(this.data.rows, this.query);
268
+ if (this.searchMode) {
269
+ if (matches("tui.select.cancel")) {
270
+ this.query = "";
271
+ this.searchMode = false;
272
+ this.selectedIndex = 0;
273
+ this.scroll = 0;
274
+ } else if (matches("tui.select.confirm")) {
275
+ this.searchMode = false;
276
+ } else if (key === "\x7f" || key === "\b") {
277
+ this.query = this.query.slice(0, -1);
278
+ this.selectedIndex = 0;
279
+ this.scroll = 0;
280
+ } else if (isPrintable(key) && key !== "/") {
281
+ this.query += key;
282
+ this.selectedIndex = 0;
283
+ this.scroll = 0;
284
+ }
285
+ this.tui.requestRender();
286
+ return;
287
+ }
288
+ if (matches("tui.select.cancel")) {
289
+ if (this.query) {
290
+ this.query = "";
291
+ this.selectedIndex = 0;
292
+ this.scroll = 0;
293
+ } else this.onAction({ type: "close" });
294
+ } else if (key === "/") {
295
+ this.searchMode = true;
296
+ } else if (matches("tui.select.up")) {
297
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
298
+ } else if (matches("tui.select.down")) {
299
+ this.selectedIndex = Math.min(Math.max(0, rows.length - 1), this.selectedIndex + 1);
300
+ } else if (matches("tui.select.confirm")) {
301
+ const row = rows[this.selectedIndex];
302
+ if (row) this.onAction({ type: "edit", id: row.id });
303
+ } else if (key.toLocaleLowerCase() === "r") {
304
+ const row = rows[this.selectedIndex];
305
+ if (row) this.onAction({ type: "reset", id: row.id });
306
+ } else if (key.toLocaleLowerCase() === "m") {
307
+ this.onAction({ type: "models" });
308
+ } else if (key.toLocaleLowerCase() === "d") {
309
+ this.onAction({ type: "diagnostics" });
310
+ }
311
+ this.tui.requestRender();
312
+ }
313
+
314
+ /** Exposed to command handlers so reopening after a native dialog preserves navigation. */
315
+ get viewState(): { query: string; selectedId: SettingId | undefined } {
316
+ return { query: this.query, selectedId: this.selectedId };
317
+ }
318
+
319
+ get pageWidth(): number {
320
+ return this.lastWidth;
321
+ }
322
+ }
@@ -1,28 +0,0 @@
1
- /**
2
- * Persona persistence — remember the last explicitly-selected persona and restore
3
- * it on the next start. Mirrors pi-subagents-persona: the state file lives in the
4
- * global persona folder (`<agentDir>/persona/state.json`); session-start restore
5
- * only READS it, only user gestures (f8 / /persona) write it. Best-effort: any
6
- * I/O failure is swallowed (persistence must never break a session).
7
- */
8
-
9
- import * as fs from "node:fs";
10
- import * as path from "node:path";
11
-
12
- export function readLastPersona(stateFile: string): string | undefined {
13
- try {
14
- const data = JSON.parse(fs.readFileSync(stateFile, "utf-8")) as { lastPersona?: unknown };
15
- return typeof data.lastPersona === "string" && data.lastPersona ? data.lastPersona : undefined;
16
- } catch {
17
- return undefined;
18
- }
19
- }
20
-
21
- export function writeLastPersona(stateFile: string, name: string | undefined): void {
22
- try {
23
- fs.mkdirSync(path.dirname(stateFile), { recursive: true });
24
- fs.writeFileSync(stateFile, `${JSON.stringify({ lastPersona: name ?? null }, null, 2)}\n`);
25
- } catch {
26
- /* best-effort */
27
- }
28
- }