pi-advisor-flow 0.6.0 → 0.8.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.
Files changed (93) hide show
  1. package/CHANGELOG.md +34 -17
  2. package/README.md +10 -8
  3. package/dist/index.js +2852 -868
  4. package/extensions/index.ts +2 -1
  5. package/package.json +55 -40
  6. package/src/attachments.ts +11 -10
  7. package/src/commands/activation-preparation.ts +1 -0
  8. package/src/commands/activation.ts +2 -0
  9. package/src/commands/lifecycle.ts +1 -0
  10. package/src/commands/manual-command.ts +7 -1
  11. package/src/commands/manual-consultation.ts +103 -79
  12. package/src/commands/manual-progress.ts +7 -5
  13. package/src/commands/model-commands.ts +1 -1
  14. package/src/commands/model-options.ts +14 -3
  15. package/src/commands/model-picker.ts +1 -0
  16. package/src/commands/registration.ts +1 -0
  17. package/src/commands/renderers.ts +67 -55
  18. package/src/commands/runtime.ts +16 -14
  19. package/src/commands/settings-commands.ts +10 -4
  20. package/src/commands/settings-persistence.ts +64 -2
  21. package/src/commands/types.ts +2 -0
  22. package/src/commands.ts +0 -1
  23. package/src/config/args.ts +1 -1
  24. package/src/config/defaults.ts +83 -1
  25. package/src/config/schema.ts +152 -15
  26. package/src/config/state.ts +80 -2
  27. package/src/config/storage.ts +38 -22
  28. package/src/config/types.ts +45 -3
  29. package/src/config/validation.ts +33 -21
  30. package/src/config.ts +3 -0
  31. package/src/content-utils.ts +24 -4
  32. package/src/conversation.ts +16 -15
  33. package/src/git.ts +10 -3
  34. package/src/herdr-block.ts +83 -0
  35. package/src/herdr-shared.ts +108 -0
  36. package/src/herdr.ts +30 -192
  37. package/src/jev/client.ts +338 -0
  38. package/src/jev/failure.ts +19 -0
  39. package/src/jev/key-store.ts +267 -0
  40. package/src/jev/ledger.ts +228 -0
  41. package/src/jev/questions.ts +122 -0
  42. package/src/jev/state.ts +50 -0
  43. package/src/jev/transport.ts +63 -0
  44. package/src/model-stream.ts +35 -37
  45. package/src/outcomes.ts +20 -12
  46. package/src/pi-settings.ts +4 -9
  47. package/src/preferences.ts +4 -2
  48. package/src/redaction.ts +10 -12
  49. package/src/scout-context.ts +59 -60
  50. package/src/scout-curation.ts +4 -13
  51. package/src/scout-groups.ts +178 -175
  52. package/src/scout-protocol.ts +74 -70
  53. package/src/scout-reconstruct.ts +1 -1
  54. package/src/scout.ts +115 -73
  55. package/src/session-state.ts +148 -39
  56. package/src/tool-result-cap.ts +18 -17
  57. package/src/tools/consult-context.ts +7 -15
  58. package/src/tools/consultation.ts +14 -4
  59. package/src/tools/gate-policy.ts +1 -0
  60. package/src/tools/gate-protocol.ts +36 -23
  61. package/src/tools/jev-filter.ts +204 -0
  62. package/src/tools/jev-turn-gate.ts +203 -0
  63. package/src/tools/loop-gate.ts +32 -20
  64. package/src/tools/model-access.ts +46 -0
  65. package/src/tools/outage-notifier.ts +30 -0
  66. package/src/tools/prompts.ts +20 -25
  67. package/src/tools/register-ask-advisor.ts +73 -17
  68. package/src/tools/register-lifecycle.ts +22 -0
  69. package/src/tools/register-outcome.ts +2 -0
  70. package/src/tools/register-renderers.ts +149 -73
  71. package/src/tools/registration.ts +15 -0
  72. package/src/tools/render-advisor-result.ts +77 -35
  73. package/src/tools/render-common.ts +16 -19
  74. package/src/tools/scout-status.ts +42 -27
  75. package/src/tools/types.ts +11 -0
  76. package/src/tools.ts +0 -1
  77. package/src/ui/jev-setup-submenu.ts +350 -0
  78. package/src/ui/manual-dialog-render.ts +1 -0
  79. package/src/ui/manual-dialog.ts +39 -43
  80. package/src/ui/masked-input.ts +62 -0
  81. package/src/ui/model-multi-selector.ts +9 -0
  82. package/src/ui/model-selector-adapter.ts +31 -0
  83. package/src/ui/model-selector.ts +14 -161
  84. package/src/ui/searchable-model-list.ts +230 -0
  85. package/src/ui/settings-formatting.ts +24 -14
  86. package/src/ui/settings-items.ts +200 -2
  87. package/src/ui/settings-list-adapter.ts +15 -12
  88. package/src/ui/settings-mutations.ts +169 -47
  89. package/src/ui/settings-selector.ts +18 -9
  90. package/src/ui/text-setting-submenu.ts +3 -6
  91. package/src/ui/types.ts +25 -0
  92. package/src/ui.ts +0 -1
  93. package/src/usage.ts +29 -20
@@ -0,0 +1,350 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
3
+ import type { Component, Focusable } from "@earendil-works/pi-tui";
4
+ import { noul } from "@typesafe-ai/sdk";
5
+
6
+ import { jevClientFromCredentials } from "../jev/client.ts";
7
+ import {
8
+ clearKeyTypeSafeKey,
9
+ consumePlaintextKeyWarning,
10
+ removeTypeSafeKeyFromAdvisorJson,
11
+ writeKeyTypeSafeKey,
12
+ } from "../jev/key-store.ts";
13
+ import type { JevKeyStoreResult } from "../jev/key-store.ts";
14
+ import { resolveJevTransport } from "../jev/transport.ts";
15
+ import type { JevCredentials } from "../jev/transport.ts";
16
+ import { MaskedInput } from "./masked-input.ts";
17
+ import type { RenderRequester } from "./types.ts";
18
+
19
+ export interface JevSetupSubmenuOptions {
20
+ currentValue: string;
21
+ done: (selectedValue?: string) => void;
22
+ theme: Theme;
23
+ tui: RenderRequester;
24
+ }
25
+
26
+ export interface JevSetupDeps {
27
+ clearStoredKey?: () => Promise<JevKeyStoreResult>;
28
+ removePlaintextKey?: () => JevKeyStoreResult;
29
+ resolveTransport?: () => Promise<JevCredentials | undefined>;
30
+ verify?: (credentials: JevCredentials) => Promise<JevFailureLike>;
31
+ writeKey?: (key: string) => Promise<JevKeyStoreResult>;
32
+ }
33
+
34
+ interface JevFailureLike {
35
+ message?: string;
36
+ ok: boolean;
37
+ }
38
+
39
+ type SetupMode = "menu" | "key-entry" | "verifying";
40
+ type SetupAction =
41
+ | "verify-enable"
42
+ | "verify-again"
43
+ | "disable"
44
+ | "disable-clear"
45
+ | "enter-key"
46
+ | "done";
47
+
48
+ const ACTION_LABELS: Record<SetupAction, string> = {
49
+ disable: "Disable",
50
+ "disable-clear": "Disable and clear stored key",
51
+ done: "Done",
52
+ "enter-key": "Enter a TypeSafe API key",
53
+ "verify-again": "Verify again",
54
+ "verify-enable": "Verify and enable",
55
+ };
56
+
57
+ const fireAndForget = async (action: Promise<unknown>): Promise<void> => {
58
+ try {
59
+ await action;
60
+ } catch {
61
+ // Background setup actions surface their own notices.
62
+ }
63
+ };
64
+
65
+ const transportLabel = (credentials: JevCredentials): string => {
66
+ if (credentials.transport === "openrouter") {
67
+ return "OpenRouter (reusing pi login)";
68
+ }
69
+ switch (credentials.source) {
70
+ case "advisor-json": {
71
+ return "TypeSafe (key: advisor.json — plaintext, not recommended)";
72
+ }
73
+ case "bun-secrets": {
74
+ return "TypeSafe (key: Bun.secrets)";
75
+ }
76
+ case "file": {
77
+ return "TypeSafe (key: stored file, mode 0600)";
78
+ }
79
+ default: {
80
+ return "TypeSafe (key: TYPESAFE_API_KEY)";
81
+ }
82
+ }
83
+ };
84
+
85
+ const defaultVerify = async (credentials: JevCredentials) => {
86
+ const client = jevClientFromCredentials(credentials);
87
+ try {
88
+ await client.ask(
89
+ { purpose: "pi-advisor setup verification" },
90
+ { verified: noul("Answer yes.") }
91
+ );
92
+ return { ok: true };
93
+ } catch (error) {
94
+ return {
95
+ message: error instanceof Error ? error.message : String(error),
96
+ ok: false,
97
+ };
98
+ }
99
+ };
100
+
101
+ /** Guided Jev credentials setup: detects a reusable login, verifies live
102
+ * before enabling, and never echoes an entered key back. */
103
+ export class JevSetupSubmenu implements Component, Focusable {
104
+ private readonly options: JevSetupSubmenuOptions;
105
+ private readonly deps: JevSetupDeps;
106
+ private readonly maskedInput: MaskedInput;
107
+ private credentials: JevCredentials | undefined;
108
+ private mode: SetupMode = "menu";
109
+ private notice: string | undefined;
110
+ private selectedIndex = 0;
111
+ private _focused = true;
112
+
113
+ constructor(options: JevSetupSubmenuOptions, deps: JevSetupDeps = {}) {
114
+ this.options = options;
115
+ this.deps = {
116
+ clearStoredKey: deps.clearStoredKey ?? clearKeyTypeSafeKey,
117
+ removePlaintextKey:
118
+ deps.removePlaintextKey ?? removeTypeSafeKeyFromAdvisorJson,
119
+ resolveTransport: deps.resolveTransport ?? (() => resolveJevTransport()),
120
+ verify: deps.verify ?? defaultVerify,
121
+ writeKey: deps.writeKey ?? writeKeyTypeSafeKey,
122
+ };
123
+ this.maskedInput = new MaskedInput({
124
+ onEscape: () => this.options.done(),
125
+ onSubmit: (value) => this.submitEnteredKey(value),
126
+ placeholder: "Paste a TypeSafe API key",
127
+ });
128
+ void fireAndForget(this.refresh());
129
+ }
130
+
131
+ get focused(): boolean {
132
+ return this._focused;
133
+ }
134
+
135
+ set focused(value: boolean) {
136
+ this._focused = value;
137
+ this.maskedInput.focused = value;
138
+ }
139
+
140
+ invalidate(): void {
141
+ this.maskedInput.invalidate();
142
+ }
143
+
144
+ handleInput(keyData: string): void {
145
+ if (this.mode === "key-entry") {
146
+ this.maskedInput.handleInput(keyData);
147
+ this.options.tui.requestRender();
148
+ return;
149
+ }
150
+ if (this.mode !== "menu") {
151
+ return;
152
+ }
153
+ const actions = this.actions();
154
+ if (
155
+ matchesKey(keyData, Key.down) ||
156
+ keyData === "\u001B[B" ||
157
+ keyData === "\u001BOB"
158
+ ) {
159
+ this.selectedIndex = (this.selectedIndex + 1) % actions.length;
160
+ } else if (
161
+ matchesKey(keyData, Key.up) ||
162
+ keyData === "\u001B[A" ||
163
+ keyData === "\u001BOA"
164
+ ) {
165
+ this.selectedIndex =
166
+ (this.selectedIndex - 1 + actions.length) % actions.length;
167
+ } else if (matchesKey(keyData, Key.enter) || keyData === "\r") {
168
+ this.activate(actions[this.selectedIndex]);
169
+ } else {
170
+ return;
171
+ }
172
+ this.options.tui.requestRender();
173
+ }
174
+
175
+ render(width: number): string[] {
176
+ const { theme } = this.options;
177
+ const enabled = this.options.currentValue === "On";
178
+ const lines = [
179
+ theme.fg("accent", theme.bold(" Jev consultation filter")),
180
+ "",
181
+ ];
182
+ if (this.credentials) {
183
+ lines.push(
184
+ ` Transport: ${transportLabel(this.credentials)}`,
185
+ ` Filter: ${enabled ? "On" : "Off"}`
186
+ );
187
+ } else if (this.mode === "verifying") {
188
+ lines.push(" Checking available Jev credentials…");
189
+ } else {
190
+ lines.push(
191
+ " No Jev credentials found. Enter a TypeSafe API key below, or add",
192
+ " an OpenRouter login in pi; it is reused automatically."
193
+ );
194
+ }
195
+ if (this.notice) {
196
+ lines.push("", theme.fg("warning", ` ${this.notice}`));
197
+ }
198
+ lines.push("");
199
+ if (this.mode === "verifying") {
200
+ lines.push(" Verifying with a live Jev call…");
201
+ } else if (this.mode === "key-entry") {
202
+ lines.push(
203
+ ` ${this.maskedInput.render(Math.max(10, width - 4))[0] ?? ""}`,
204
+ theme.fg("dim", " Enter: verify · Esc: cancel")
205
+ );
206
+ } else {
207
+ for (const [index, label] of this.labels().entries()) {
208
+ const prefix = index === this.selectedIndex ? "→ " : " ";
209
+ lines.push(`${prefix}${label}`);
210
+ }
211
+ }
212
+ return lines.map((line) => truncateToWidth(line, width));
213
+ }
214
+
215
+ private actions(): SetupAction[] {
216
+ if (!this.credentials) {
217
+ return ["enter-key", "done"];
218
+ }
219
+ const enabled = this.options.currentValue === "On";
220
+ const actions: SetupAction[] = [enabled ? "verify-again" : "verify-enable"];
221
+ actions.push("disable");
222
+ if (
223
+ this.credentials.source === "bun-secrets" ||
224
+ this.credentials.source === "file"
225
+ ) {
226
+ actions.push("disable-clear");
227
+ }
228
+ actions.push("done");
229
+ return actions;
230
+ }
231
+
232
+ private labels(): string[] {
233
+ return this.actions().map((action) => ACTION_LABELS[action]);
234
+ }
235
+
236
+ private async refresh(): Promise<void> {
237
+ const wasVerifying = this.mode === "verifying";
238
+ this.mode = "verifying";
239
+ if (!wasVerifying) {
240
+ this.options.tui.requestRender();
241
+ }
242
+ this.credentials = await this.deps.resolveTransport?.();
243
+ this.mode = "menu";
244
+ this.selectedIndex = 0;
245
+ if (this.credentials?.source === "advisor-json") {
246
+ this.notice ??= consumePlaintextKeyWarning();
247
+ }
248
+ this.options.tui.requestRender();
249
+ }
250
+
251
+ private activate(action: SetupAction): void {
252
+ if (action === "done") {
253
+ this.options.done();
254
+ return;
255
+ }
256
+ if (action === "enter-key") {
257
+ this.mode = "key-entry";
258
+ return;
259
+ }
260
+ if (action === "disable") {
261
+ this.notice = undefined;
262
+ this.options.done("Off");
263
+ return;
264
+ }
265
+ if (action === "disable-clear") {
266
+ void fireAndForget(this.disableAndClear());
267
+ return;
268
+ }
269
+ if (action === "verify-again" || action === "verify-enable") {
270
+ void fireAndForget(this.verifyAndEnable());
271
+ }
272
+ }
273
+
274
+ private async disableAndClear(): Promise<void> {
275
+ const clear = this.deps.clearStoredKey;
276
+ if (!clear) {
277
+ return;
278
+ }
279
+ const result = await clear();
280
+ this.credentials = undefined;
281
+ this.notice = result.message;
282
+ this.options.done("Off");
283
+ }
284
+
285
+ private async verifyAndEnable(): Promise<void> {
286
+ const { credentials } = this;
287
+ if (!(credentials && this.deps.verify)) {
288
+ return;
289
+ }
290
+ this.mode = "verifying";
291
+ this.notice = undefined;
292
+ this.options.tui.requestRender();
293
+ const outcome = await this.deps.verify(credentials);
294
+ this.mode = "menu";
295
+ if (!outcome.ok) {
296
+ this.notice = `Verification failed: ${outcome.message ?? "unknown error"}`;
297
+ this.options.tui.requestRender();
298
+ return;
299
+ }
300
+ if (
301
+ credentials.transport === "typesafe" &&
302
+ credentials.source === "advisor-json"
303
+ ) {
304
+ const stored = await this.deps.writeKey?.(credentials.apiKey);
305
+ if (!stored?.ok) {
306
+ this.notice =
307
+ stored?.message ??
308
+ "Storing the key failed; the plaintext advisor.json key keeps working.";
309
+ this.options.tui.requestRender();
310
+ return;
311
+ }
312
+ const removed = this.deps.removePlaintextKey?.();
313
+ this.notice = removed?.ok
314
+ ? "Key moved from advisor.json into the secure store."
315
+ : `Stored securely, but ${removed?.message ?? "removing the plaintext copy failed; remove it yourself."}`;
316
+ }
317
+ this.options.done("On");
318
+ }
319
+
320
+ private async submitEnteredKey(value: string): Promise<void> {
321
+ const key = value.trim();
322
+ if (!key) {
323
+ return;
324
+ }
325
+ if (!this.deps.verify) {
326
+ return;
327
+ }
328
+ this.mode = "verifying";
329
+ this.options.tui.requestRender();
330
+ const outcome = await this.deps.verify({
331
+ apiKey: key,
332
+ transport: "typesafe",
333
+ });
334
+ this.mode = "menu";
335
+ if (!outcome.ok) {
336
+ this.notice = `Verification failed: ${outcome.message ?? "unknown error"}`;
337
+ this.options.tui.requestRender();
338
+ return;
339
+ }
340
+ const stored = await this.deps.writeKey?.(key);
341
+ if (!stored?.ok) {
342
+ this.notice = stored?.message ?? "Storing the key failed.";
343
+ this.options.tui.requestRender();
344
+ return;
345
+ }
346
+ this.notice = `${stored.message} Verification succeeded.`;
347
+ await this.refresh();
348
+ this.options.done("On");
349
+ }
350
+ }
@@ -3,6 +3,7 @@ import {
3
3
  visibleWidth,
4
4
  wrapTextWithAnsi,
5
5
  } from "@earendil-works/pi-tui";
6
+
6
7
  import type { GitContextLevel } from "../git.ts";
7
8
  import type { ManualAdvisorDialogView } from "./types.ts";
8
9
 
@@ -1,29 +1,34 @@
1
- import {
2
- type Component,
3
- Editor,
4
- type EditorTheme,
5
- type Focusable,
6
- Key,
7
- type Keybindings,
8
- type KeyId,
9
- matchesKey,
1
+ import { Editor, Key, matchesKey } from "@earendil-works/pi-tui";
2
+ import type {
3
+ Component,
4
+ EditorTheme,
5
+ Focusable,
6
+ Keybindings,
7
+ KeyId,
10
8
  } from "@earendil-works/pi-tui";
11
- import {
12
- clampGitContextLevel,
13
- GIT_CONTEXT_LEVELS,
14
- type GitContextLevel,
15
- } from "../git.ts";
9
+
10
+ import { clampGitContextLevel, GIT_CONTEXT_LEVELS } from "../git.ts";
11
+ import type { GitContextLevel } from "../git.ts";
16
12
  import { renderManualAdvisorDialog } from "./manual-dialog-render.ts";
17
13
  import type {
18
14
  ManualAdvisorDialogOptions,
19
15
  ManualAdvisorFocus,
16
+ ManualAdvisorRequest,
20
17
  } from "./types.ts";
21
18
 
22
- // Keep the keybinding components separate from Socket's URL-string heuristic.
19
+ // SAFETY: the joined literal is exactly the registered "tui.input.tab" keybinding path.
23
20
  const TUI_INPUT_TAB: keyof Keybindings = ["tui", "input", "tab"].join(
24
21
  "."
25
22
  ) as keyof Keybindings;
26
23
 
24
+ interface ManualDialogState {
25
+ completed: boolean;
26
+ focused: boolean;
27
+ }
28
+
29
+ const isShiftTab = (keyData: string): boolean =>
30
+ matchesKey(keyData, Key.shift("tab"));
31
+
27
32
  /** The small, non-persistent /advisor-manual form; callers own budget and consultation decisions. */
28
33
  export class ManualAdvisorDialog implements Component, Focusable {
29
34
  private readonly options: ManualAdvisorDialogOptions;
@@ -32,7 +37,7 @@ export class ManualAdvisorDialog implements Component, Focusable {
32
37
  private gitIndex: number;
33
38
  private actionIndex = 0;
34
39
  private focusTarget: ManualAdvisorFocus = "editor";
35
- private readonly state: { completed: boolean; focused: boolean } = {
40
+ private readonly state: ManualDialogState = {
36
41
  completed: false,
37
42
  focused: false,
38
43
  };
@@ -54,7 +59,7 @@ export class ManualAdvisorDialog implements Component, Focusable {
54
59
  // while the configured level remains the hard ceiling.
55
60
  this.gitLevels = GIT_CONTEXT_LEVELS.filter(
56
61
  (level) => clampGitContextLevel(level, options.gitContext) === level
57
- ).reverse();
62
+ ).toReversed();
58
63
  this.gitIndex = Math.max(0, this.gitLevels.indexOf(options.gitContext));
59
64
 
60
65
  const editorTheme: EditorTheme = {
@@ -90,7 +95,7 @@ export class ManualAdvisorDialog implements Component, Focusable {
90
95
  this.cancel();
91
96
  return;
92
97
  }
93
- if (this.isShiftTab(keyData)) {
98
+ if (isShiftTab(keyData)) {
94
99
  this.changeFocus(-1);
95
100
  return;
96
101
  }
@@ -98,24 +103,12 @@ export class ManualAdvisorDialog implements Component, Focusable {
98
103
  this.changeFocus(1);
99
104
  return;
100
105
  }
101
-
102
- switch (this.focusTarget) {
103
- case "editor":
104
- // Editor owns Enter/Shift+Enter and all cursor/editing semantics. Its
105
- // onSubmit callback above is the sole editor submission path. At the
106
- // edge of the message, a directional key also moves to the adjacent
107
- // form control so the Git selector is reachable without Tab.
108
- this.handleEditorInput(keyData);
109
- return;
110
- case "git":
111
- this.handleGitInput(keyData);
112
- return;
113
- case "actions":
114
- this.handleActionInput(keyData);
115
- return;
116
- default:
117
- return;
118
- }
106
+ const handlers: Record<ManualAdvisorFocus, (keyData: string) => void> = {
107
+ actions: (key) => this.handleActionInput(key),
108
+ editor: (key) => this.handleEditorInput(key),
109
+ git: (key) => this.handleGitInput(key),
110
+ };
111
+ handlers[this.focusTarget](keyData);
119
112
  }
120
113
 
121
114
  render(width: number): string[] {
@@ -137,6 +130,10 @@ export class ManualAdvisorDialog implements Component, Focusable {
137
130
  }
138
131
 
139
132
  private handleEditorInput(keyData: string): void {
133
+ // Editor owns Enter/Shift+Enter and all cursor/editing semantics. Its
134
+ // onSubmit callback above is the sole editor submission path. At the
135
+ // edge of the message, a directional key also moves to the adjacent
136
+ // form control so the Git selector is reachable without Tab.
140
137
  let direction: -1 | 1 | undefined;
141
138
  if (this.matches(keyData, "tui.editor.cursorDown", Key.down)) {
142
139
  direction = 1;
@@ -257,10 +254,6 @@ export class ManualAdvisorDialog implements Component, Focusable {
257
254
  );
258
255
  }
259
256
 
260
- private isShiftTab(keyData: string): boolean {
261
- return matchesKey(keyData, Key.shift("tab"));
262
- }
263
-
264
257
  private isCancel(keyData: string): boolean {
265
258
  return (
266
259
  matchesKey(keyData, Key.escape) ||
@@ -284,10 +277,13 @@ export class ManualAdvisorDialog implements Component, Focusable {
284
277
  return;
285
278
  }
286
279
  this.state.completed = true;
287
- this.options.onSubmit({
280
+ const request: ManualAdvisorRequest = {
288
281
  gitContext: this.gitLevels[this.gitIndex] ?? "off",
289
- ...(message ? { message } : {}),
290
- });
282
+ };
283
+ if (message) {
284
+ request.message = message;
285
+ }
286
+ this.options.onSubmit(request);
291
287
  }
292
288
 
293
289
  private cancel(): void {
@@ -0,0 +1,62 @@
1
+ import { Input, truncateToWidth } from "@earendil-works/pi-tui";
2
+ import type { Component, Focusable } from "@earendil-works/pi-tui";
3
+
4
+ export interface MaskedInputOptions {
5
+ onEscape?: () => void;
6
+ onSubmit?: (value: string) => void;
7
+ placeholder?: string;
8
+ }
9
+
10
+ type InputWithCursor = Input & { cursor: number };
11
+
12
+ /** Single-line secret input rendering bullets; the value never reaches the screen. */
13
+ export class MaskedInput implements Component, Focusable {
14
+ private readonly input: Input;
15
+ private readonly options: MaskedInputOptions;
16
+ private _focused = true;
17
+
18
+ constructor(options: MaskedInputOptions = {}) {
19
+ this.options = options;
20
+ this.input = new Input({ placeholder: options.placeholder });
21
+ this.input.focused = true;
22
+ this.input.onSubmit = (value) => options.onSubmit?.(value);
23
+ this.input.onEscape = () => options.onEscape?.();
24
+ }
25
+
26
+ get focused(): boolean {
27
+ return this._focused;
28
+ }
29
+
30
+ set focused(value: boolean) {
31
+ this._focused = value;
32
+ this.input.focused = value;
33
+ }
34
+
35
+ getValue(): string {
36
+ return this.input.getValue();
37
+ }
38
+
39
+ setValue(value: string): void {
40
+ this.input.setValue(value);
41
+ }
42
+
43
+ handleInput(keyData: string): void {
44
+ this.input.handleInput(keyData);
45
+ }
46
+
47
+ invalidate(): void {
48
+ this.input.invalidate();
49
+ }
50
+
51
+ render(width: number): string[] {
52
+ const value = this.input.getValue();
53
+ // SAFETY: compatibility-only read of Input's private cursor; Input does not expose it.
54
+ const { cursor } = this.input as InputWithCursor;
55
+ const masked = `${"•".repeat(Math.min(cursor, value.length))}█${"•".repeat(Math.max(0, value.length - cursor))}`;
56
+ const placeholder =
57
+ value.length === 0 && this.options.placeholder
58
+ ? this.options.placeholder
59
+ : masked;
60
+ return [truncateToWidth(placeholder, Math.max(1, width))];
61
+ }
62
+ }
@@ -0,0 +1,9 @@
1
+ import { ModelSelectorAdapter } from "./model-selector-adapter.ts";
2
+ import { SearchableModelList } from "./searchable-model-list.ts";
3
+ import type { SearchableModelMultiSelectorOptions } from "./types.ts";
4
+
5
+ export class SearchableModelMultiSelector extends ModelSelectorAdapter {
6
+ constructor(options: SearchableModelMultiSelectorOptions) {
7
+ super(new SearchableModelList(options));
8
+ }
9
+ }
@@ -0,0 +1,31 @@
1
+ import type { Component, Focusable } from "@earendil-works/pi-tui";
2
+
3
+ import type { SearchableModelList } from "./searchable-model-list.ts";
4
+
5
+ export class ModelSelectorAdapter implements Component, Focusable {
6
+ protected readonly list: SearchableModelList;
7
+
8
+ constructor(list: SearchableModelList) {
9
+ this.list = list;
10
+ }
11
+
12
+ get focused(): boolean {
13
+ return this.list.focused;
14
+ }
15
+
16
+ set focused(value: boolean) {
17
+ this.list.focused = value;
18
+ }
19
+
20
+ invalidate(): void {
21
+ this.list.invalidate();
22
+ }
23
+
24
+ render(width: number): string[] {
25
+ return this.list.render(width);
26
+ }
27
+
28
+ handleInput(keyData: string): void {
29
+ this.list.handleInput(keyData);
30
+ }
31
+ }