@sherif-fanous/pi-presets-plus 0.1.0
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/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/package.json +74 -0
- package/src/activation/active-state.ts +25 -0
- package/src/activation/apply.ts +236 -0
- package/src/activation/baseline.ts +32 -0
- package/src/activation/clear.ts +434 -0
- package/src/activation/dirty.ts +69 -0
- package/src/activation/drift-handlers.ts +71 -0
- package/src/activation/drift.ts +77 -0
- package/src/activation/same-set.ts +32 -0
- package/src/activation/state-matches.ts +29 -0
- package/src/activation/thinking.ts +54 -0
- package/src/commands/presets/clear.ts +18 -0
- package/src/commands/presets/index.ts +9 -0
- package/src/commands/presets/notify.ts +22 -0
- package/src/commands/presets/reload.ts +28 -0
- package/src/commands/presets/router.ts +139 -0
- package/src/commands/presets/status.ts +262 -0
- package/src/flag.ts +88 -0
- package/src/hotkey-conflicts.ts +136 -0
- package/src/hotkey-reload-baseline.ts +112 -0
- package/src/hotkeys.ts +104 -0
- package/src/index.ts +171 -0
- package/src/messages.ts +34 -0
- package/src/store/api.ts +262 -0
- package/src/store/load.ts +175 -0
- package/src/store/merge.ts +69 -0
- package/src/store/paths.ts +38 -0
- package/src/store/save.ts +75 -0
- package/src/store/validate.ts +195 -0
- package/src/types.ts +169 -0
- package/src/ui/confirm.ts +126 -0
- package/src/ui/editor.ts +1617 -0
- package/src/ui/filter.ts +79 -0
- package/src/ui/frame.ts +109 -0
- package/src/ui/hotkey-input.ts +242 -0
- package/src/ui/info-dialog.ts +118 -0
- package/src/ui/labels.ts +51 -0
- package/src/ui/picker-state.ts +151 -0
- package/src/ui/picker.ts +982 -0
- package/src/ui/reload-prompt.ts +59 -0
- package/src/ui/status.ts +55 -0
- package/src/ui/widgets.ts +274 -0
package/src/ui/filter.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure preset filtering helpers for the picker UI.
|
|
3
|
+
*
|
|
4
|
+
* Owns ranking and scope filtering of loaded presets; it does NOT own
|
|
5
|
+
* rendering, picker state, or activation.
|
|
6
|
+
*/
|
|
7
|
+
import type { LoadedPreset } from "../types.js";
|
|
8
|
+
|
|
9
|
+
/** Three-way scope toggle exposed by the picker header. */
|
|
10
|
+
export type ScopeFilter = "all" | "user" | "project";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Hide presets outside the selected scope.
|
|
14
|
+
*
|
|
15
|
+
* `all` returns a shallow copy so callers can chain mutations safely;
|
|
16
|
+
* `user`/`project` apply a simple equality filter. Shadowed globals are
|
|
17
|
+
* returned in `user` scope because their project shadow is hidden — the
|
|
18
|
+
* user is still allowed to inspect/activate the global directly.
|
|
19
|
+
*/
|
|
20
|
+
export function applyScopeFilter(
|
|
21
|
+
presets: readonly LoadedPreset[],
|
|
22
|
+
scopeFilter: ScopeFilter,
|
|
23
|
+
): LoadedPreset[] {
|
|
24
|
+
switch (scopeFilter) {
|
|
25
|
+
case "all":
|
|
26
|
+
return [...presets];
|
|
27
|
+
case "user":
|
|
28
|
+
return presets.filter((preset) => preset.scope === "user");
|
|
29
|
+
case "project":
|
|
30
|
+
return presets.filter((preset) => preset.scope === "project");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Rank presets by a free-text query.
|
|
36
|
+
*
|
|
37
|
+
* Empty queries preserve input order. Non-empty queries return literal
|
|
38
|
+
* case-insensitive substring matches first, followed by subsequence-only
|
|
39
|
+
* matches. Ordering within each group is stable so storage/user ordering
|
|
40
|
+
* remains meaningful after filtering.
|
|
41
|
+
*/
|
|
42
|
+
export function rankPresets(
|
|
43
|
+
items: readonly LoadedPreset[],
|
|
44
|
+
query: string,
|
|
45
|
+
): LoadedPreset[] {
|
|
46
|
+
const normalizedQuery = query.trim().toLowerCase();
|
|
47
|
+
|
|
48
|
+
if (normalizedQuery.length === 0) return [...items];
|
|
49
|
+
|
|
50
|
+
const literalMatches: LoadedPreset[] = [];
|
|
51
|
+
const fuzzyMatches: LoadedPreset[] = [];
|
|
52
|
+
|
|
53
|
+
for (const item of items) {
|
|
54
|
+
const haystack =
|
|
55
|
+
`${item.name} ${item.provider}/${item.model}`.toLowerCase();
|
|
56
|
+
|
|
57
|
+
if (haystack.includes(normalizedQuery)) {
|
|
58
|
+
literalMatches.push(item);
|
|
59
|
+
} else if (subsequenceMatch(haystack, normalizedQuery)) {
|
|
60
|
+
fuzzyMatches.push(item);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return [...literalMatches, ...fuzzyMatches];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function subsequenceMatch(haystack: string, query: string): boolean {
|
|
68
|
+
let queryIndex = 0;
|
|
69
|
+
|
|
70
|
+
for (
|
|
71
|
+
let haystackIndex = 0;
|
|
72
|
+
haystackIndex < haystack.length && queryIndex < query.length;
|
|
73
|
+
haystackIndex++
|
|
74
|
+
) {
|
|
75
|
+
if (haystack[haystackIndex] === query[queryIndex]) queryIndex++;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return queryIndex === query.length;
|
|
79
|
+
}
|
package/src/ui/frame.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small terminal-frame layout helpers shared by custom TUI surfaces.
|
|
3
|
+
*
|
|
4
|
+
* Owns width-safe border, padding, and centering primitives reused by
|
|
5
|
+
* preset dialogs; it does NOT own picker state, activation, or any
|
|
6
|
+
* specific dialog content.
|
|
7
|
+
*/
|
|
8
|
+
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
9
|
+
|
|
10
|
+
export interface DialogFrameOptions {
|
|
11
|
+
readonly bodyLines: readonly string[];
|
|
12
|
+
readonly footer: string;
|
|
13
|
+
readonly title: string;
|
|
14
|
+
readonly width: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function centerText(text: string, width: number): string {
|
|
18
|
+
const textWidth = visibleWidth(text);
|
|
19
|
+
|
|
20
|
+
if (textWidth >= width) return truncateToWidth(text, width, "…");
|
|
21
|
+
|
|
22
|
+
const leftPadding = Math.floor((width - textWidth) / 2);
|
|
23
|
+
const rightPadding = width - textWidth - leftPadding;
|
|
24
|
+
|
|
25
|
+
return `${" ".repeat(leftPadding)}${text}${" ".repeat(rightPadding)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Wrap content in a left/right border and pad/truncate it to the requested
|
|
30
|
+
* width. Width is visual-column based, so ANSI escape sequences do not push
|
|
31
|
+
* the right border out of alignment.
|
|
32
|
+
*/
|
|
33
|
+
export function frameLine(content: string, width: number): string {
|
|
34
|
+
if (width <= 2) return truncateToWidth("││", width, "");
|
|
35
|
+
|
|
36
|
+
return `│${padToWidth(content, width - 2)}│`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Render a `left + fill + right` border segment, e.g. `┌────┐`. Falls back
|
|
41
|
+
* to a truncated `leftright` pair when the requested width is too narrow
|
|
42
|
+
* for any fill characters.
|
|
43
|
+
*/
|
|
44
|
+
export function frameSegment(
|
|
45
|
+
left: string,
|
|
46
|
+
fill: string,
|
|
47
|
+
right: string,
|
|
48
|
+
width: number,
|
|
49
|
+
): string {
|
|
50
|
+
if (width <= 2) return truncateToWidth(`${left}${right}`, width, "");
|
|
51
|
+
|
|
52
|
+
return `${left}${fill.repeat(width - 2)}${right}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function padToWidth(
|
|
56
|
+
text: string,
|
|
57
|
+
width: number,
|
|
58
|
+
fill = " ",
|
|
59
|
+
ellipsis = "…",
|
|
60
|
+
): string {
|
|
61
|
+
const truncated = truncateToWidth(text, width, ellipsis);
|
|
62
|
+
const paddingWidth = Math.max(0, width - visibleWidth(truncated));
|
|
63
|
+
|
|
64
|
+
return `${truncated}${fill.repeat(paddingWidth)}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function renderDialogFrame(options: DialogFrameOptions): string[] {
|
|
68
|
+
const frameWidth = Math.max(2, options.width);
|
|
69
|
+
const bodyWidth = Math.max(1, frameWidth - 2);
|
|
70
|
+
const lines = [
|
|
71
|
+
frameSegment("┌", "─", "┐", frameWidth),
|
|
72
|
+
frameLine(centerText(options.title, bodyWidth), frameWidth),
|
|
73
|
+
frameLine("", frameWidth),
|
|
74
|
+
...options.bodyLines.map((line) => frameLine(line, frameWidth)),
|
|
75
|
+
frameLine(options.footer, frameWidth),
|
|
76
|
+
frameSegment("└", "─", "┘", frameWidth),
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
return lines.map((line) => truncateToWidth(line, frameWidth, ""));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function wrapBody(text: string, width: number): string[] {
|
|
83
|
+
const safeWidth = Math.max(1, width);
|
|
84
|
+
|
|
85
|
+
return text
|
|
86
|
+
.split("\n")
|
|
87
|
+
.flatMap((line) => (line.length === 0 ? [""] : wrapWords(line, safeWidth)));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function wrapWords(text: string, width: number): string[] {
|
|
91
|
+
const words = text.split(/\s+/);
|
|
92
|
+
const lines: string[] = [];
|
|
93
|
+
let current = "";
|
|
94
|
+
|
|
95
|
+
for (const word of words) {
|
|
96
|
+
const candidate = current.length === 0 ? word : `${current} ${word}`;
|
|
97
|
+
|
|
98
|
+
if (candidate.length <= width) {
|
|
99
|
+
current = candidate;
|
|
100
|
+
} else {
|
|
101
|
+
if (current.length > 0) lines.push(current);
|
|
102
|
+
current = word;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (current.length > 0) lines.push(current);
|
|
107
|
+
|
|
108
|
+
return lines.length > 0 ? lines : [""];
|
|
109
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hotkey parsing and conflict helpers for preset editor fields.
|
|
3
|
+
*
|
|
4
|
+
* Owns normalizing user-entered key combinations and comparing them against
|
|
5
|
+
* pi built-ins or loaded presets; it does NOT register shortcuts or persist
|
|
6
|
+
* preset files.
|
|
7
|
+
*/
|
|
8
|
+
import type { LoadedPreset } from "../types.js";
|
|
9
|
+
|
|
10
|
+
export interface ParsedHotkey {
|
|
11
|
+
readonly key: string;
|
|
12
|
+
readonly modifiers: readonly HotkeyModifier[];
|
|
13
|
+
readonly normalized: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type HotkeyModifier = "alt" | "ctrl" | "shift";
|
|
17
|
+
|
|
18
|
+
export type ParseHotkeyResult =
|
|
19
|
+
| { ok: true; parsed: ParsedHotkey }
|
|
20
|
+
| { ok: false; reason: string };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Modifier ordering used for the `normalized` form of a parsed hotkey.
|
|
24
|
+
*
|
|
25
|
+
* The order is fixed (not user-visible) so two hotkeys with the same set of
|
|
26
|
+
* modifiers normalize to the same string regardless of how the user typed
|
|
27
|
+
* them. Conflict detection compares normalized strings only — the
|
|
28
|
+
* presentation layer is free to render modifiers in display order.
|
|
29
|
+
*/
|
|
30
|
+
const MODIFIER_ORDER: readonly HotkeyModifier[] = ["ctrl", "shift", "alt"];
|
|
31
|
+
const MODIFIERS = new Set<string>(MODIFIER_ORDER);
|
|
32
|
+
const SPECIAL_KEYS = new Set([
|
|
33
|
+
"backspace",
|
|
34
|
+
"clear",
|
|
35
|
+
"delete",
|
|
36
|
+
"down",
|
|
37
|
+
"end",
|
|
38
|
+
"enter",
|
|
39
|
+
"esc",
|
|
40
|
+
"escape",
|
|
41
|
+
"home",
|
|
42
|
+
"insert",
|
|
43
|
+
"left",
|
|
44
|
+
"pageDown",
|
|
45
|
+
"pageUp",
|
|
46
|
+
"return",
|
|
47
|
+
"right",
|
|
48
|
+
"space",
|
|
49
|
+
"tab",
|
|
50
|
+
"up",
|
|
51
|
+
]);
|
|
52
|
+
// TODO(change-7): shifted-symbol equivalents are layout-dependent
|
|
53
|
+
// (e.g. on a US layout `ctrl+!` and `ctrl+shift+1` produce the same
|
|
54
|
+
// physical chord but normalize to different strings here, so conflict
|
|
55
|
+
// detection between the two will miss). Revisit when per-preset hotkeys
|
|
56
|
+
// actually register with pi-tui's keybinding manager.
|
|
57
|
+
const SYMBOL_KEYS = new Set([
|
|
58
|
+
"`",
|
|
59
|
+
"-",
|
|
60
|
+
"=",
|
|
61
|
+
"[",
|
|
62
|
+
"]",
|
|
63
|
+
"\\",
|
|
64
|
+
";",
|
|
65
|
+
"'",
|
|
66
|
+
",",
|
|
67
|
+
".",
|
|
68
|
+
"/",
|
|
69
|
+
"!",
|
|
70
|
+
"@",
|
|
71
|
+
"#",
|
|
72
|
+
"$",
|
|
73
|
+
"%",
|
|
74
|
+
"^",
|
|
75
|
+
"&",
|
|
76
|
+
"*",
|
|
77
|
+
"(",
|
|
78
|
+
")",
|
|
79
|
+
"_",
|
|
80
|
+
"+",
|
|
81
|
+
"|",
|
|
82
|
+
"~",
|
|
83
|
+
"{",
|
|
84
|
+
"}",
|
|
85
|
+
":",
|
|
86
|
+
"<",
|
|
87
|
+
">",
|
|
88
|
+
"?",
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
/** Defaults copied from pi's documented `docs/keybindings.md`. */
|
|
92
|
+
export const PI_BUILTIN_HOTKEYS: readonly string[] = [
|
|
93
|
+
"alt+b",
|
|
94
|
+
"alt+backspace",
|
|
95
|
+
"alt+d",
|
|
96
|
+
"alt+delete",
|
|
97
|
+
"alt+down",
|
|
98
|
+
"alt+enter",
|
|
99
|
+
"alt+f",
|
|
100
|
+
"alt+left",
|
|
101
|
+
"alt+right",
|
|
102
|
+
"alt+up",
|
|
103
|
+
"alt+v",
|
|
104
|
+
"alt+y",
|
|
105
|
+
"backspace",
|
|
106
|
+
"ctrl+-",
|
|
107
|
+
"ctrl+]",
|
|
108
|
+
"ctrl+alt+]",
|
|
109
|
+
"ctrl+a",
|
|
110
|
+
"ctrl+b",
|
|
111
|
+
"ctrl+backspace",
|
|
112
|
+
"ctrl+c",
|
|
113
|
+
"ctrl+d",
|
|
114
|
+
"ctrl+e",
|
|
115
|
+
"ctrl+f",
|
|
116
|
+
"ctrl+g",
|
|
117
|
+
"ctrl+k",
|
|
118
|
+
"ctrl+l",
|
|
119
|
+
"ctrl+left",
|
|
120
|
+
"ctrl+n",
|
|
121
|
+
"ctrl+o",
|
|
122
|
+
"ctrl+p",
|
|
123
|
+
"ctrl+r",
|
|
124
|
+
"ctrl+right",
|
|
125
|
+
"ctrl+s",
|
|
126
|
+
"ctrl+t",
|
|
127
|
+
"ctrl+u",
|
|
128
|
+
"ctrl+v",
|
|
129
|
+
"ctrl+w",
|
|
130
|
+
"ctrl+x",
|
|
131
|
+
"ctrl+y",
|
|
132
|
+
"ctrl+z",
|
|
133
|
+
"delete",
|
|
134
|
+
"down",
|
|
135
|
+
"end",
|
|
136
|
+
"enter",
|
|
137
|
+
"escape",
|
|
138
|
+
"home",
|
|
139
|
+
"left",
|
|
140
|
+
"pageDown",
|
|
141
|
+
"pageUp",
|
|
142
|
+
"right",
|
|
143
|
+
"shift+ctrl+o",
|
|
144
|
+
"shift+ctrl+p",
|
|
145
|
+
"shift+enter",
|
|
146
|
+
"shift+l",
|
|
147
|
+
"shift+t",
|
|
148
|
+
"shift+tab",
|
|
149
|
+
"tab",
|
|
150
|
+
"up",
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
const NORMALIZED_PI_BUILTINS = new Set(
|
|
154
|
+
PI_BUILTIN_HOTKEYS.map((hotkey) => parseHotkey(hotkey))
|
|
155
|
+
.filter((result): result is { ok: true; parsed: ParsedHotkey } => result.ok)
|
|
156
|
+
.map((result) => result.parsed.normalized),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
export function findConflictingPreset(
|
|
160
|
+
parsedKey: ParsedHotkey,
|
|
161
|
+
loadedPresets: readonly LoadedPreset[],
|
|
162
|
+
excludeName?: string,
|
|
163
|
+
): LoadedPreset | undefined {
|
|
164
|
+
return loadedPresets.find((preset) => {
|
|
165
|
+
if (preset.name === excludeName) return false;
|
|
166
|
+
if (!preset.hotkey) return false;
|
|
167
|
+
|
|
168
|
+
const parsed = parseHotkey(preset.hotkey);
|
|
169
|
+
|
|
170
|
+
return parsed.ok && parsed.parsed.normalized === parsedKey.normalized;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function isPiBuiltin(parsedKey: ParsedHotkey): boolean {
|
|
175
|
+
return NORMALIZED_PI_BUILTINS.has(parsedKey.normalized);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function parseHotkey(text: string): ParseHotkeyResult {
|
|
179
|
+
const raw = text.trim().toLowerCase();
|
|
180
|
+
|
|
181
|
+
if (raw.length === 0) return { ok: false, reason: "hotkey is empty" };
|
|
182
|
+
|
|
183
|
+
const parts = raw
|
|
184
|
+
.split("+")
|
|
185
|
+
.map((part) => part.trim())
|
|
186
|
+
.filter((part) => part.length > 0);
|
|
187
|
+
|
|
188
|
+
if (parts.length === 0) return { ok: false, reason: "hotkey is empty" };
|
|
189
|
+
|
|
190
|
+
const modifierSet = new Set<HotkeyModifier>();
|
|
191
|
+
let key: string | undefined;
|
|
192
|
+
|
|
193
|
+
for (const part of parts) {
|
|
194
|
+
if (MODIFIERS.has(part)) {
|
|
195
|
+
const modifier = part as HotkeyModifier;
|
|
196
|
+
|
|
197
|
+
if (modifierSet.has(modifier)) {
|
|
198
|
+
return { ok: false, reason: `duplicate modifier "${modifier}"` };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
modifierSet.add(modifier);
|
|
202
|
+
} else if (key === undefined) {
|
|
203
|
+
key = normalizeKey(part);
|
|
204
|
+
} else {
|
|
205
|
+
return { ok: false, reason: "hotkey must contain exactly one key" };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!key) return { ok: false, reason: "hotkey is missing a key" };
|
|
210
|
+
if (!isValidKey(key))
|
|
211
|
+
return { ok: false, reason: `unsupported key "${key}"` };
|
|
212
|
+
|
|
213
|
+
const modifiers = MODIFIER_ORDER.filter((modifier) =>
|
|
214
|
+
modifierSet.has(modifier),
|
|
215
|
+
);
|
|
216
|
+
const normalized = [...modifiers, key].join("+");
|
|
217
|
+
|
|
218
|
+
return { ok: true, parsed: { key, modifiers, normalized } };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function isValidKey(key: string): boolean {
|
|
222
|
+
if (/^[a-z0-9]$/.test(key)) return true;
|
|
223
|
+
if (/^f(?:[1-9]|1[0-2])$/.test(key)) return true;
|
|
224
|
+
if (SPECIAL_KEYS.has(key)) return true;
|
|
225
|
+
|
|
226
|
+
return SYMBOL_KEYS.has(key);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function normalizeKey(key: string): string {
|
|
230
|
+
switch (key) {
|
|
231
|
+
case "return":
|
|
232
|
+
return "enter";
|
|
233
|
+
case "escape":
|
|
234
|
+
return "esc";
|
|
235
|
+
case "pagedown":
|
|
236
|
+
return "pageDown";
|
|
237
|
+
case "pageup":
|
|
238
|
+
return "pageUp";
|
|
239
|
+
default:
|
|
240
|
+
return key;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only informational overlay for picker-owned multi-line output.
|
|
3
|
+
*
|
|
4
|
+
* Owns tone styling, framing, and Enter/Esc dismissal; it does NOT own
|
|
5
|
+
* command formatting, activation state, or picker state restoration.
|
|
6
|
+
*/
|
|
7
|
+
import { renderDialogFrame, wrapBody } from "./frame.js";
|
|
8
|
+
import type {
|
|
9
|
+
ExtensionCommandContext,
|
|
10
|
+
Theme,
|
|
11
|
+
} from "@mariozechner/pi-coding-agent";
|
|
12
|
+
import {
|
|
13
|
+
Key,
|
|
14
|
+
matchesKey,
|
|
15
|
+
type Component,
|
|
16
|
+
type Focusable,
|
|
17
|
+
} from "@mariozechner/pi-tui";
|
|
18
|
+
|
|
19
|
+
export interface InfoDialogOptions {
|
|
20
|
+
readonly body: string;
|
|
21
|
+
readonly title: string;
|
|
22
|
+
readonly tone?: InfoDialogTone;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type InfoDialogTone = "info" | "warning" | "error";
|
|
26
|
+
|
|
27
|
+
type ResolvedInfoDialogOptions = InfoDialogOptions & { tone: InfoDialogTone };
|
|
28
|
+
|
|
29
|
+
class InfoDialogComponent implements Component, Focusable {
|
|
30
|
+
private resolved = false;
|
|
31
|
+
private _focused = false;
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
private readonly options: ResolvedInfoDialogOptions,
|
|
35
|
+
private readonly theme: Theme,
|
|
36
|
+
private readonly done: () => void,
|
|
37
|
+
) {}
|
|
38
|
+
|
|
39
|
+
get focused(): boolean {
|
|
40
|
+
return this._focused;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
set focused(value: boolean) {
|
|
44
|
+
this._focused = value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
handleInput(input: string): void {
|
|
48
|
+
if (matchesKey(input, Key.enter) || matchesKey(input, Key.escape)) {
|
|
49
|
+
this.finish();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
invalidate(): void {
|
|
54
|
+
// No cached layout or external data to invalidate.
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
render(width: number): string[] {
|
|
58
|
+
const frameWidth = Math.max(2, width);
|
|
59
|
+
const bodyWidth = Math.max(1, frameWidth - 2);
|
|
60
|
+
const titleColor = toneTitleColor(this.options.tone);
|
|
61
|
+
|
|
62
|
+
return renderDialogFrame({
|
|
63
|
+
bodyLines: [
|
|
64
|
+
...wrapBody(this.options.body, bodyWidth - 4).map(
|
|
65
|
+
(line) => ` ${line}`,
|
|
66
|
+
),
|
|
67
|
+
"",
|
|
68
|
+
],
|
|
69
|
+
footer: this.theme.fg("dim", footerHint(this.options.tone)),
|
|
70
|
+
title: this.theme.fg(titleColor, this.theme.bold(this.options.title)),
|
|
71
|
+
width: frameWidth,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private finish(): void {
|
|
76
|
+
if (this.resolved) return;
|
|
77
|
+
this.resolved = true;
|
|
78
|
+
this.done();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function openInfoDialog(
|
|
83
|
+
ctx: Pick<ExtensionCommandContext, "ui">,
|
|
84
|
+
options: InfoDialogOptions,
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
await ctx.ui.custom<void>(
|
|
87
|
+
(_tui, theme, _keybindings, done) =>
|
|
88
|
+
new InfoDialogComponent(
|
|
89
|
+
{ ...options, tone: options.tone ?? "info" },
|
|
90
|
+
theme,
|
|
91
|
+
done,
|
|
92
|
+
),
|
|
93
|
+
{
|
|
94
|
+
overlay: true,
|
|
95
|
+
overlayOptions: {
|
|
96
|
+
anchor: "center",
|
|
97
|
+
margin: 2,
|
|
98
|
+
maxHeight: "90%",
|
|
99
|
+
minWidth: 48,
|
|
100
|
+
width: "90%",
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function footerHint(tone: InfoDialogTone): string {
|
|
107
|
+
if (tone === "error") return " Press Enter or Esc to dismiss error ";
|
|
108
|
+
if (tone === "warning") return " Press Enter or Esc to dismiss warning ";
|
|
109
|
+
|
|
110
|
+
return " Press Enter or Esc to dismiss ";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function toneTitleColor(tone: InfoDialogTone): Parameters<Theme["fg"]>[0] {
|
|
114
|
+
if (tone === "error") return "error";
|
|
115
|
+
if (tone === "warning") return "warning";
|
|
116
|
+
|
|
117
|
+
return "accent";
|
|
118
|
+
}
|
package/src/ui/labels.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical user-facing label vocabulary.
|
|
3
|
+
*
|
|
4
|
+
* Owns repeated field labels, action labels, and dialog titles shared across
|
|
5
|
+
* surfaces; it does NOT compose full prose messages or own rendering layout.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Field labels shared by status, clear, editor rows, and picker cards.
|
|
9
|
+
export const MODEL_LABEL = "Model";
|
|
10
|
+
export const THINKING_LABEL = "Thinking level";
|
|
11
|
+
export const TOOLS_LABEL = "Tools";
|
|
12
|
+
export const PRESET_LABEL = "Preset";
|
|
13
|
+
export const SCOPE_LABEL = "Scope";
|
|
14
|
+
export const STATUS_LABEL = "Status";
|
|
15
|
+
|
|
16
|
+
// Per-surface composed labels used by status and related summaries.
|
|
17
|
+
export const BASELINE_MODEL_LABEL = "Baseline model";
|
|
18
|
+
export const BASELINE_THINKING_LABEL = "Baseline thinking level";
|
|
19
|
+
export const BASELINE_TOOLS_LABEL = "Baseline tools";
|
|
20
|
+
export const PRESET_MODEL_LABEL = "Preset model";
|
|
21
|
+
export const PRESET_THINKING_LABEL = "Preset thinking level";
|
|
22
|
+
export const PRESET_TOOLS_LABEL = "Preset tools";
|
|
23
|
+
export const CURRENT_MODEL_LABEL = "Current model";
|
|
24
|
+
export const CURRENT_THINKING_LABEL = "Current thinking level";
|
|
25
|
+
export const CURRENT_TOOLS_LABEL = "Current tools";
|
|
26
|
+
export const RESTORE_LABEL = "Restore";
|
|
27
|
+
|
|
28
|
+
// Dialog titles shared by overlays and formatter headings.
|
|
29
|
+
export const STATUS_DIALOG_TITLE = "Preset Status";
|
|
30
|
+
export const CLEAR_DIALOG_TITLE = "Preset cleared";
|
|
31
|
+
export const ACTIVATION_FAILED_TITLE = "Activation failed";
|
|
32
|
+
export const RELOAD_PROMPT_TITLE = "Reload Pi?";
|
|
33
|
+
export const MOVE_PRESET_TITLE = "Move preset?";
|
|
34
|
+
|
|
35
|
+
// Action labels, including single-use footer labels kept here for auditability.
|
|
36
|
+
export const ACTIVATE_LABEL = "Activate";
|
|
37
|
+
export const FILTER_LABEL = "Filter";
|
|
38
|
+
export const STATUS_ACTION_LABEL = "Status";
|
|
39
|
+
export const NEW_LABEL = "New";
|
|
40
|
+
export const EDIT_LABEL = "Edit";
|
|
41
|
+
export const DUPLICATE_LABEL = "Duplicate";
|
|
42
|
+
export const DELETE_LABEL = "Delete";
|
|
43
|
+
export const CLEAR_LABEL = "Clear";
|
|
44
|
+
export const REORDER_LABEL = "Reorder";
|
|
45
|
+
export const CLOSE_LABEL = "Close";
|
|
46
|
+
export const LIST_LABEL = "List";
|
|
47
|
+
export const CURSOR_LABEL = "Cursor";
|
|
48
|
+
export const MOVE_LABEL = "Move";
|
|
49
|
+
export const SAVE_LABEL = "Save";
|
|
50
|
+
export const CANCEL_LABEL = "Cancel";
|
|
51
|
+
export const TEST_LABEL = "Test (apply temporarily)";
|