@aeondave/pi-persona 1.16.3 → 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.
- package/AGENTS.md +22 -3
- package/CHANGELOG.md +56 -0
- package/README.md +18 -2
- package/docs/ARCHITECTURE.md +58 -7
- package/docs/REFERENCE.md +125 -3
- package/docs/TELEMETRY.md +8 -0
- package/package.json +2 -2
- package/src/core/config.ts +1 -1
- package/src/core/settings.ts +251 -0
- package/src/engine/adapter.ts +1 -1
- package/src/engine/async.ts +53 -13
- package/src/engine/child.ts +1 -1
- package/src/engine/errors.ts +1 -0
- package/src/engine/inproc.ts +3 -3
- package/src/engine/stream.ts +20 -1
- package/src/extension/companion-settings.ts +29 -0
- package/src/extension/hooks.ts +47 -12
- package/src/extension/persona-model-settings.ts +318 -0
- package/src/extension/persona-settings.ts +459 -0
- package/src/extension.ts +257 -50
- package/src/orchestration/sdk.ts +2 -0
- package/src/persona/config-store.ts +6 -195
- package/src/persona/controller.ts +16 -3
- package/src/persona/model-participants.ts +1 -1
- package/src/persona/settings-store.ts +636 -0
- package/src/telemetry/contract.ts +7 -5
- package/src/tools/delegate-tool.ts +6 -6
- package/src/tools/delegate.ts +3 -1
- package/src/tools/exocom.ts +14 -1
- package/src/ui/persona-settings.ts +322 -0
- package/src/persona/state.ts +0 -28
|
@@ -325,15 +325,16 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
|
|
|
325
325
|
// The writer classification travels WITH the run (d.tracker metadata), not in a side Set keyed by
|
|
326
326
|
// the returned id: a thunk that throws synchronously settles the run inside launch(), so a
|
|
327
327
|
// registration after launch() returns would re-insert an already-dead run and leak it.
|
|
328
|
-
const id = d.tracker.launch({ agent, task, label, ...(model ? { model } : {}), mutates: mayMutateWorkspace(runSpec) }, (onProgress, runId) => {
|
|
328
|
+
const id = d.tracker.launch({ agent, task, label, ...(model ? { model } : {}), mutates: mayMutateWorkspace(runSpec), queued: true }, (onProgress, runId) => {
|
|
329
329
|
const nodeId = `async:${runId}`;
|
|
330
330
|
// A real, HARD stop for the async run (a steer is only a soft request the child may
|
|
331
331
|
// ignore): aborting this signal makes the engine call the sub-agent's `agent.abort()`.
|
|
332
332
|
const ac = new AbortController();
|
|
333
333
|
d.stopRegistry.set(nodeId, () => ac.abort());
|
|
334
334
|
const execute = () =>
|
|
335
|
-
asyncSlots.with(() =>
|
|
336
|
-
d.
|
|
335
|
+
asyncSlots.with(() => {
|
|
336
|
+
d.tracker.markAdmitted(runId);
|
|
337
|
+
return d.buildEngine(
|
|
337
338
|
undefined,
|
|
338
339
|
(snap) => {
|
|
339
340
|
onProgress(snap);
|
|
@@ -350,9 +351,8 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
|
|
|
350
351
|
// the queue-time seed, so the elapsed reading and stall badge are both honest.
|
|
351
352
|
const now = Date.now();
|
|
352
353
|
d.agentTree.update(nodeId, { detail: "", startedAt: now, lastAdvanceAt: now });
|
|
353
|
-
})
|
|
354
|
-
|
|
355
|
-
);
|
|
354
|
+
});
|
|
355
|
+
}, ac.signal);
|
|
356
356
|
// A per-call semaphore composes with the process-wide ceiling. Acquire it first so a
|
|
357
357
|
// queued member of a serial batch never occupies a global slot while waiting for its
|
|
358
358
|
// predecessor. This makes `concurrency: 1` mean the same thing in sync and async mode.
|
package/src/tools/delegate.ts
CHANGED
|
@@ -654,7 +654,9 @@ export async function runDelegate(
|
|
|
654
654
|
(steer) => onLegSteerable?.(0, steer),
|
|
655
655
|
);
|
|
656
656
|
const text = r.ok ? r.output || "(no output)" : `Agent "${params.agent}" failed: ${r.error ?? "(no detail)"}`;
|
|
657
|
-
|
|
657
|
+
const terminalView = viewOf(params.agent, label, r);
|
|
658
|
+
onProgress?.([{ ...terminalView }]);
|
|
659
|
+
return { text, results: [r], views: [terminalView], ok: r.ok };
|
|
658
660
|
}
|
|
659
661
|
|
|
660
662
|
return { text: "delegate: provide either { agent, task } or { tasks: [{ agent, task }, ...] }", results: [], views: [], ok: false };
|
package/src/tools/exocom.ts
CHANGED
|
@@ -36,6 +36,11 @@ export const MAX_BROADCAST_DETAIL_ITEMS = 64;
|
|
|
36
36
|
const MODEL_BROADCAST_ID_SAMPLE = 12;
|
|
37
37
|
const MODEL_BROADCAST_FAILURE_SAMPLE = 8;
|
|
38
38
|
|
|
39
|
+
/** Keep a correlation token only when it is already safe and bounded; never truncate a route token. */
|
|
40
|
+
function isExactBoundedMetadataText(value: unknown, max: number): value is string {
|
|
41
|
+
return typeof value === "string" && value.length > 0 && normalizeMetadataText(value, max, "") === value;
|
|
42
|
+
}
|
|
43
|
+
|
|
39
44
|
const ExocomListParams = Type.Object({
|
|
40
45
|
offset: Type.Optional(Type.Integer({ minimum: 0, maximum: 1_000_000, description: "Zero-based page offset into the live peer list." })),
|
|
41
46
|
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_MODEL_PEER_ROWS, description: `Maximum peers to show (default ${MAX_MODEL_PEER_ROWS}).` })),
|
|
@@ -68,6 +73,8 @@ type ExocomSendDetails =
|
|
|
68
73
|
queuedCount: number;
|
|
69
74
|
failedCount: number;
|
|
70
75
|
msg_ids: string[];
|
|
76
|
+
/** Bounded transport receipts, retaining the exact route token needed to correlate each peer. */
|
|
77
|
+
sent: Array<{ target: string; msg_id: string }>;
|
|
71
78
|
failed: Array<{ target: string; error: string }>;
|
|
72
79
|
omittedMsgIds: number;
|
|
73
80
|
omittedFailures: number;
|
|
@@ -386,6 +393,11 @@ export function registerExocomTools(
|
|
|
386
393
|
`${normalizeMetadataText(item.target, 80, "peer")}: ${rejectedTargets.has(item.target) ? "rejected by the peer; reason quoted below" : normalizeMetadataText(item.error, 160, "send failed")}`,
|
|
387
394
|
);
|
|
388
395
|
const omittedFailures = failed.length - shownFailures.length;
|
|
396
|
+
const shownReceipts = sent.flatMap((item) =>
|
|
397
|
+
isExactBoundedMetadataText(item.target, 80) && isExactBoundedMetadataText(item.msg_id, 128)
|
|
398
|
+
? [{ target: item.target, msg_id: item.msg_id }]
|
|
399
|
+
: [],
|
|
400
|
+
).slice(0, MAX_BROADCAST_DETAIL_ITEMS);
|
|
389
401
|
const action = omittedIds > 0 || omittedFailures > 0
|
|
390
402
|
? `; ${[
|
|
391
403
|
shownIds.length > 0 ? `msg_ids: ${shownIds.join(", ")}${omittedIds > 0 ? `; +${omittedIds} more msg_ids omitted` : ""}` : "",
|
|
@@ -402,11 +414,12 @@ export function registerExocomTools(
|
|
|
402
414
|
queuedCount: sent.length,
|
|
403
415
|
failedCount: failed.length,
|
|
404
416
|
msg_ids: sent.slice(0, MAX_BROADCAST_DETAIL_ITEMS).map((item) => normalizeMetadataText(item.msg_id, 128, "msg")),
|
|
417
|
+
sent: shownReceipts,
|
|
405
418
|
failed: failed.slice(0, MAX_BROADCAST_DETAIL_ITEMS).map((failure) => ({
|
|
406
419
|
target: normalizeMetadataText(failure.target, 80, "peer"),
|
|
407
420
|
error: normalizeMetadataText(failure.error, 240, "send failed"),
|
|
408
421
|
})),
|
|
409
|
-
omittedMsgIds: Math.max(0, sent.length -
|
|
422
|
+
omittedMsgIds: Math.max(0, sent.length - shownReceipts.length),
|
|
410
423
|
omittedFailures: Math.max(0, failed.length - MAX_BROADCAST_DETAIL_ITEMS),
|
|
411
424
|
},
|
|
412
425
|
};
|
|
@@ -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
|
+
}
|
package/src/persona/state.ts
DELETED
|
@@ -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
|
-
}
|