@sns-myagent/cli 0.3.8 → 0.3.9

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 (68) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +63 -24
  3. package/bin/snsagent.js +3 -41
  4. package/dist/cli.js +3267 -3288
  5. package/package.json +4 -4
  6. package/src/adapters/telegram/handler.ts +1 -1
  7. package/src/agents/ensemble.ts +3 -4
  8. package/src/agents/executor.ts +129 -0
  9. package/src/async/notifier.ts +1 -1
  10. package/src/cli/config-cli.ts +1 -1
  11. package/src/cli/entry.ts +2 -2
  12. package/src/cli/gallery-fixtures/misc.ts +3 -3
  13. package/src/cli/grep-cli.ts +3 -3
  14. package/src/cli/grievances-cli.ts +2 -2
  15. package/src/cli/index.ts +186 -31
  16. package/src/cli/plugin-cli.ts +5 -5
  17. package/src/cli/setup-cli.ts +3 -3
  18. package/src/cli/setup-model-picker.ts +1 -1
  19. package/src/cli/shell-cli.ts +1 -1
  20. package/src/cli/ssh-cli.ts +1 -1
  21. package/src/cli/stats-cli.ts +1 -1
  22. package/src/cli/tiny-models-cli.ts +3 -3
  23. package/src/cli/ttsr-cli.ts +2 -2
  24. package/src/cli/update-cli.ts +3 -3
  25. package/src/cli/usage-cli.ts +1 -1
  26. package/src/cli-commands.ts +6 -6
  27. package/src/commands/acp.ts +2 -2
  28. package/src/commands/chat.ts +3 -3
  29. package/src/commands/install.ts +5 -5
  30. package/src/commands/say.ts +4 -4
  31. package/src/commands/setup.ts +1 -1
  32. package/src/config/settings-schema.ts +1 -1
  33. package/src/config/sns-config.ts +1 -1
  34. package/src/extensibility/plugins/loader.ts +1 -1
  35. package/src/extensibility/plugins/manager.ts +2 -2
  36. package/src/hindsight/client.ts +1 -1
  37. package/src/internal-urls/docs-index.generated.txt +2 -0
  38. package/src/main.ts +4 -0
  39. package/src/modes/acp/acp-agent.ts +3 -3
  40. package/src/modes/components/plugin-settings.ts +2 -2
  41. package/src/modes/components/welcome.ts +66 -160
  42. package/src/modes/interactive-mode.ts +2 -2
  43. package/src/modes/print-mode.ts +2 -2
  44. package/src/modes/setup-version.ts +1 -1
  45. package/src/modes/setup-wizard/scenes/byok-setup.ts +354 -0
  46. package/src/modes/setup-wizard/scenes/outro.ts +2 -2
  47. package/src/modes/setup-wizard/scenes/providers.ts +6 -5
  48. package/src/modes/setup-wizard/scenes/splash.ts +3 -3
  49. package/src/modes/setup-wizard/wizard-overlay.ts +2 -2
  50. package/src/modes/theme/dark.json +21 -20
  51. package/src/modes/theme/light.json +30 -29
  52. package/src/modes/utils/ui-helpers.ts +1 -1
  53. package/src/slash-commands/builtin-registry.ts +6 -3
  54. package/src/stt/recorder.ts +3 -3
  55. package/src/tts/player.ts +1 -1
  56. package/src/tts/tts-client.ts +1 -1
  57. package/src/tui/chat-blocks.ts +4 -4
  58. package/src/tui/chat-ui.ts +3 -3
  59. package/src/tui/code-cell.ts +3 -3
  60. package/src/tui/command-palette.ts +3 -3
  61. package/src/tui/splash.ts +12 -18
  62. package/src/ui/banner.ts +51 -66
  63. package/src/ui/chat-prompt.ts +2 -2
  64. package/src/ui/colors.ts +17 -14
  65. package/src/ui/error-display.ts +2 -2
  66. package/src/ui/gradient.ts +3 -3
  67. package/src/ui/memory-toast.ts +5 -5
  68. package/src/ui/status-bar.ts +1 -1
@@ -11,7 +11,7 @@ import { theme } from "../../modes/theme/theme";
11
11
  import tipsText from "./tips.txt" with { type: "text" };
12
12
 
13
13
  // Local override — upstream APP_NAME still reports "omp"/"pi-utils" identity.
14
- const APP_NAME = "SnsAgent";
14
+ const APP_NAME = "snsagent";
15
15
 
16
16
  /** Tips embedded at build time, one per line; blanks dropped. */
17
17
  const TIPS: readonly string[] = tipsText
@@ -236,160 +236,66 @@ export class WelcomeComponent implements Component {
236
236
  }
237
237
 
238
238
  #renderLines(termWidth: number): string[] {
239
- // Box dimensions - responsive with max width and small-terminal support
240
- const maxWidth = 100;
241
- const boxWidth = Math.min(maxWidth, Math.max(0, termWidth - 2));
242
- if (boxWidth < 4) {
243
- return [];
244
- }
245
- const dualContentWidth = boxWidth - 3; // 3 = │ + │ + │
246
- const preferredLeftCol = 26;
247
- const minLeftCol = 12; // logo width
248
- const minRightCol = 20;
249
- const leftMinContentWidth = Math.max(
250
- minLeftCol,
251
- visibleWidth("Welcome back!"),
252
- visibleWidth(this.modelName),
253
- visibleWidth(this.providerName),
254
- );
255
- const desiredLeftCol = Math.min(preferredLeftCol, Math.max(minLeftCol, Math.floor(dualContentWidth * 0.35)));
256
- const dualLeftCol =
257
- dualContentWidth >= minRightCol + 1
258
- ? Math.min(desiredLeftCol, dualContentWidth - minRightCol)
259
- : Math.max(1, dualContentWidth - 1);
260
- const dualRightCol = Math.max(1, dualContentWidth - dualLeftCol);
261
- const showRightColumn = dualLeftCol >= leftMinContentWidth && dualRightCol >= minRightCol;
262
- const leftCol = showRightColumn ? dualLeftCol : boxWidth - 2;
263
- const rightCol = showRightColumn ? dualRightCol : 0;
264
-
265
- // Logo: pick a frame from the intro animation if active, else the resting frame.
239
+ const width = Math.min(100, Math.max(0, termWidth - 4));
240
+ if (width < 4) return [];
241
+
266
242
  const logoColored = this.#currentLogoFrame();
243
+ const dot = theme.fg("accent", "●");
267
244
 
268
- // Left column - centered content
269
- const leftLines = [
270
- "",
271
- this.#centerText(theme.bold("Welcome back!"), leftCol),
272
- "",
273
- ...logoColored.map(l => this.#centerText(l, leftCol)),
274
- "",
275
- this.#centerText(theme.fg("muted", this.modelName), leftCol),
276
- this.#centerText(theme.fg("borderMuted", this.providerName), leftCol),
277
- ];
278
-
279
- // Right column separator
280
- const separatorWidth = Math.max(0, rightCol - 2); // padding on each side
281
- const separator = ` ${theme.fg("dim", theme.boxRound.horizontal.repeat(separatorWidth))}`;
282
-
283
- // Recent sessions content
284
- const sessionLines: string[] = [];
285
- if (this.recentSessions.length === 0) {
286
- sessionLines.push(` ${theme.fg("dim", "No recent sessions")}`);
287
- } else {
288
- // Reserve width for the bullet prefix (" • ") and the trailing " (timeAgo)"
289
- // so the relative time is never the part that gets truncated. The name
290
- // absorbs whatever space is left.
291
- const bulletPrefix = ` ${theme.md.bullet} `;
292
- const prefixWidth = visibleWidth(bulletPrefix);
245
+ const lines: string[] = [];
246
+
247
+ // Logo
248
+ lines.push("");
249
+ for (const l of logoColored) {
250
+ lines.push(this.#centerText(l, width));
251
+ }
252
+ lines.push("");
253
+
254
+ // Brand + version
255
+ lines.push(this.#centerText(`${dot} ${theme.bold(APP_NAME)} ${theme.fg("dim", `v${this.version}`)}`, width));
256
+ lines.push("");
257
+
258
+ // Model info
259
+ lines.push(this.#centerText(theme.fg("muted", `${this.providerName} / ${this.modelName}`), width));
260
+ lines.push("");
261
+
262
+ // Divider
263
+ lines.push(theme.fg("dim", "".repeat(width)));
264
+ lines.push("");
265
+
266
+ // Tips
267
+ lines.push(` ${theme.bold(theme.fg("accent", "●"))} ${theme.bold("Shortcuts")}`);
268
+ lines.push(` ${theme.fg("dim", "?")} ${theme.fg("muted", "keyboard shortcuts")}`);
269
+ lines.push(` ${theme.fg("dim", "/")} ${theme.fg("muted", "slash commands")}`);
270
+ lines.push(` ${theme.fg("dim", "!")} ${theme.fg("muted", "run bash")}`);
271
+ lines.push(` ${theme.fg("dim", "$")} ${theme.fg("muted", "run python")}`);
272
+ lines.push("");
273
+
274
+ // Recent sessions (if any)
275
+ if (this.recentSessions.length > 0) {
276
+ lines.push(` ${theme.bold(theme.fg("accent", "●"))} ${theme.bold("Recent")}`);
293
277
  for (const session of this.recentSessions.slice(0, WELCOME_SESSION_SLOTS)) {
294
- const timeSuffixRaw = ` (${session.timeAgo})`;
295
- const timeWidth = visibleWidth(timeSuffixRaw);
296
- const nameBudget = Math.max(1, rightCol - prefixWidth - timeWidth);
297
- const nameVis = visibleWidth(session.name);
298
- const name = nameVis > nameBudget ? truncateToWidth(session.name, nameBudget) : session.name;
299
- sessionLines.push(
300
- `${theme.fg("dim", bulletPrefix)}${theme.fg("muted", name)}${theme.fg("dim", timeSuffixRaw)}`,
301
- );
278
+ const time = `(${session.timeAgo})`;
279
+ lines.push(` ${theme.fg("dim", "·")} ${theme.fg("muted", session.name)} ${theme.fg("dim", time)}`);
302
280
  }
303
- }
304
- // Pad to the fixed slot count so the box height doesn't depend on session count.
305
- while (sessionLines.length < WELCOME_SESSION_SLOTS) {
306
- sessionLines.push("");
281
+ lines.push("");
307
282
  }
308
283
 
309
- // LSP servers content
310
- const lspLines: string[] = [];
311
- if (this.lspServers.length === 0) {
312
- lspLines.push(` ${theme.fg("dim", "No LSP servers")}`);
313
- } else {
284
+ // LSP (if any)
285
+ if (this.lspServers.length > 0) {
286
+ lines.push(` ${theme.bold(theme.fg("accent", "●"))} ${theme.bold("LSP")}`);
314
287
  for (const server of this.lspServers.slice(0, WELCOME_LSP_SLOTS)) {
315
- const icon =
316
- server.status === "ready"
317
- ? theme.styledSymbol("status.enabled", "success")
318
- : server.status === "available"
319
- ? theme.styledSymbol("status.enabled", "dim")
320
- : server.status === "connecting"
321
- ? theme.styledSymbol("status.pending", "muted")
322
- : theme.styledSymbol("status.error", "error");
323
- const exts = server.fileTypes.slice(0, 3).join(" ");
324
- lspLines.push(` ${icon} ${theme.fg("muted", server.name)} ${theme.fg("dim", exts)}`);
288
+ const icon = server.status === "ready"
289
+ ? theme.styledSymbol("status.enabled", "success")
290
+ : theme.styledSymbol("status.pending", "dim");
291
+ lines.push(` ${icon} ${theme.fg("muted", server.name)}`);
325
292
  }
326
- }
327
- // Pad to the fixed slot count so the box height doesn't depend on server count.
328
- while (lspLines.length < WELCOME_LSP_SLOTS) {
329
- lspLines.push("");
330
- }
331
-
332
- // Right column
333
- const rightLines = [
334
- ` ${theme.bold(theme.fg("accent", "Tips"))}`,
335
- ` ${theme.fg("dim", "?")}${theme.fg("muted", " for keyboard shortcuts")}`,
336
- ` ${theme.fg("dim", "#")}${theme.fg("muted", " for prompt actions")}`,
337
- ` ${theme.fg("dim", "/")}${theme.fg("muted", " for commands")}`,
338
- ` ${theme.fg("dim", "!")}${theme.fg("muted", " to run bash")}`,
339
- ` ${theme.fg("dim", "$")}${theme.fg("muted", " to run python")}`,
340
- separator,
341
- ` ${theme.bold(theme.fg("accent", "LSP Servers"))}`,
342
- ...lspLines,
343
- separator,
344
- ` ${theme.bold(theme.fg("accent", "Recent sessions"))}`,
345
- ...sessionLines,
346
- "",
347
- ];
348
-
349
- // Border characters (dim)
350
- const hChar = theme.boxRound.horizontal;
351
- const h = theme.fg("dim", hChar);
352
- const v = theme.fg("dim", theme.boxRound.vertical);
353
- const tl = theme.fg("dim", theme.boxRound.topLeft);
354
- const tr = theme.fg("dim", theme.boxRound.topRight);
355
- const bl = theme.fg("dim", theme.boxRound.bottomLeft);
356
- const br = theme.fg("dim", theme.boxRound.bottomRight);
357
-
358
- const lines: string[] = [];
359
-
360
- // Top border with embedded title
361
- const title = ` ${APP_NAME} v${this.version} `;
362
- const titlePrefixRaw = hChar.repeat(3);
363
- const titleStyled = theme.fg("dim", titlePrefixRaw) + theme.fg("muted", title);
364
- const titleVisLen = visibleWidth(titlePrefixRaw) + visibleWidth(title);
365
- const titleSpace = boxWidth - 2;
366
- if (titleVisLen >= titleSpace) {
367
- lines.push(tl + truncateToWidth(titleStyled, titleSpace) + tr);
368
- } else {
369
- const afterTitle = titleSpace - titleVisLen;
370
- lines.push(tl + titleStyled + theme.fg("dim", hChar.repeat(afterTitle)) + tr);
371
- }
372
-
373
- // Content rows
374
- const maxRows = showRightColumn ? Math.max(leftLines.length, rightLines.length) : leftLines.length;
375
- for (let i = 0; i < maxRows; i++) {
376
- const left = this.#fitToWidth(leftLines[i] ?? "", leftCol);
377
- if (showRightColumn) {
378
- const right = this.#fitToWidth(rightLines[i] ?? "", rightCol);
379
- lines.push(v + left + v + right + v);
380
- } else {
381
- lines.push(v + left + v);
382
- }
383
- }
384
- // Bottom border
385
- if (showRightColumn) {
386
- lines.push(bl + h.repeat(leftCol) + theme.fg("dim", theme.boxRound.teeUp) + h.repeat(rightCol) + br);
387
- } else {
388
- lines.push(bl + h.repeat(leftCol) + br);
293
+ lines.push("");
389
294
  }
390
295
 
391
- // Randomly picked tip, rendered directly beneath the box.
392
- lines.push(...this.#renderTip(boxWidth));
296
+ // Bottom divider + tip
297
+ lines.push(theme.fg("dim", "─".repeat(width)));
298
+ lines.push(...this.#renderTip(width));
393
299
 
394
300
  return lines;
395
301
  }
@@ -455,26 +361,26 @@ export class WelcomeComponent implements Component {
455
361
  }
456
362
  }
457
363
 
458
- export const PI_LOGO = [
459
- "██████╗███╗ ██╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗",
460
- "██╔════╝████╗ ██║██╔════╝██╔══██╗██╔═══██╗██╔════╝████╗ ██║╚══██╔══╝",
461
- "██████╗ ██╔██╗ ██║███████╗███████║██║ ██║█████╗ ██╔██╗ ██║ ██║ ",
462
- "╚════██║██║╚██╗██║╚════██║██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ ",
463
- "██████║██║ ╚████║███████║██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ ",
464
- "╚═════╝╚═╝ ╚═══╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ",
364
+ export const SNS_LOGO = [
365
+ "████████╗███████╗██████╗ ███╗ ███╗",
366
+ "╚══██╔══╝██╔════╝██╔══██╗████╗ ████║",
367
+ " ██║ █████╗ ██████╔╝██╔████╔██║",
368
+ " ██║ ██╔══╝ ██╔══██╗██║╚██╔╝██║",
369
+ " ██║ ███████╗██║ ██║██║ ╚═╝ ██║",
370
+ " ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝",
465
371
  ];
466
372
 
467
373
  /** Multi-stop palette for the diagonal gradient. */
468
374
  const GRADIENT_STOPS: ReadonlyArray<readonly [number, number, number]> = [
469
- [0, 210, 255], // #00d2ffSNS cyan
470
- [80, 150, 255], // transitional blue
471
- [123, 47, 247], // #7b2ff7 SNS purple
472
- [200, 70, 180], // transitional pink-purple
473
- [255, 107, 157], // #ff6b9d — SNS pink
375
+ [249, 115, 22], // #F97316orange-500
376
+ [234, 88, 12], // #EA580C — orange-600
377
+ [255, 140, 50], // transitional warm orange
378
+ [220, 100, 20], // transitional deep orange
379
+ [245, 130, 30], // transitional amber
474
380
  ];
475
381
 
476
382
  /** 256-color ramp fallback when truecolor isn't available. */
477
- const GRADIENT_RAMP_256 = [199, 171, 135, 99, 75, 51, 87];
383
+ const GRADIENT_RAMP_256 = [208, 209, 214, 172, 166, 130, 178];
478
384
 
479
385
  /** Half-width of the shine highlight band, expressed in gradient-t units. */
480
386
  const SHINE_HALF_WIDTH = 0.18;
@@ -583,8 +489,8 @@ function introLogoFrame(progress: number): string[] {
583
489
  const phase = ((((1 - eased) * INTRO_SWEEPS) % 1) + 1) % 1;
584
490
  const shinePos = (((progress * INTRO_SHINE_TRAVERSALS) % 1) + 1) % 1;
585
491
  const shineStrength = (1 - eased) ** 1.5;
586
- return gradientLogo(PI_LOGO, phase, { strength: shineStrength, pos: shinePos });
492
+ return gradientLogo(SNS_LOGO, phase, { strength: shineStrength, pos: shinePos });
587
493
  }
588
494
 
589
495
  /** Resting gradient frame, cached for re-renders outside of the intro. */
590
- const REST_FRAME = gradientLogo(PI_LOGO, 0);
496
+ const REST_FRAME = gradientLogo(SNS_LOGO, 0);
@@ -853,7 +853,7 @@ export class InteractiveMode implements InteractiveModeContext {
853
853
  // Load initial todos
854
854
  await this.#loadTodoList();
855
855
 
856
- // Start the UI. Cold `omp` launch opts into clearing on the first paint so
856
+ // Start the UI. Cold `snsagent` launch opts into clearing on the first paint so
857
857
  // the initial welcome frame does not append over the previous run's scrollback.
858
858
  this.ui.start({ clearScrollback: options.clearInitialTerminalHistory === true });
859
859
  pushTerminalTitle();
@@ -876,7 +876,7 @@ export class InteractiveMode implements InteractiveModeContext {
876
876
  // custom messages, branch summaries, and compaction summaries) and the user
877
877
  // set no explicit `mode_change` (which #reconcileModeFromSession just
878
878
  // restored). SDK startup metadata and extension `custom` state entries are
879
- // ignored. This way `omp --continue` (or auto-resume) that finds no recent
879
+ // ignored. This way `snsagent --continue` (or auto-resume) that finds no recent
880
880
  // session and creates a fresh one still honors the default, while a session
881
881
  // with restored context or an explicit mode keeps its reconciled mode. Scoped
882
882
  // to launch (not the switch reconciler above) so /new and the plan-approval →
@@ -2,8 +2,8 @@
2
2
  * Print mode (single-shot): Send prompts, output result, exit.
3
3
  *
4
4
  * Used for:
5
- * - `omp -p "prompt"` - text output
6
- * - `omp --mode json "prompt"` - JSON event stream
5
+ * - `snsagent -p "prompt"` - text output
6
+ * - `snsagent --mode json "prompt"` - JSON event stream
7
7
  */
8
8
  import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
9
9
  import { logger, sanitizeText } from "@oh-my-pi/pi-utils";
@@ -8,4 +8,4 @@
8
8
  * the overlay component and their TUI deps. MUST equal `max(scene.minVersion)`
9
9
  * across `ALL_SCENES`; the `setup-wizard` barrel and test suite guard it.
10
10
  */
11
- export const CURRENT_SETUP_VERSION = 1;
11
+ export const CURRENT_SETUP_VERSION = 2;
@@ -0,0 +1,354 @@
1
+ /**
2
+ * BYOK (Bring Your Own Key) setup tab.
3
+ *
4
+ * Quick-connect tab in the providers scene. User enters Base URL + API Key +
5
+ * selects API type → auto-detects models → saves to models.yml.
6
+ */
7
+ import { type Component, type Focusable, Input, type SgrMouseEvent } from "@oh-my-pi/pi-tui";
8
+ import { YAML } from "bun";
9
+ import { getAgentDir, logger } from "@oh-my-pi/pi-utils";
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+ import { theme } from "../../theme/theme";
13
+ import type { SetupSceneHost, SetupTab } from "./types";
14
+
15
+ // ─── API type options ───────────────────────────────────────────────
16
+ const API_TYPE_OPTIONS = [
17
+ { value: "openai-completions", label: "OpenAI Compatible" },
18
+ { value: "openai-responses", label: "OpenAI Responses" },
19
+ { value: "anthropic-messages", label: "Anthropic" },
20
+ { value: "google-generative-ai", label: "Google Gemini" },
21
+ { value: "azure-openai-responses", label: "Azure OpenAI" },
22
+ ] as const;
23
+
24
+ type ApiType = (typeof API_TYPE_OPTIONS)[number]["value"];
25
+
26
+ // ─── Focusable input wrapper ────────────────────────────────────────
27
+ class FieldInput implements Component, Focusable {
28
+ #input: Input;
29
+ #label: string;
30
+ #masked: boolean;
31
+
32
+ constructor(label: string, masked = false) {
33
+ this.#input = new Input();
34
+ this.#input.prompt = "";
35
+ this.#label = label;
36
+ this.#masked = masked;
37
+ }
38
+
39
+ get focused(): boolean {
40
+ return this.#input.focused;
41
+ }
42
+ set focused(value: boolean) {
43
+ this.#input.focused = value;
44
+ }
45
+ setUseTerminalCursor(v: boolean): void {
46
+ this.#input.setUseTerminalCursor(v);
47
+ }
48
+ getValue(): string {
49
+ return this.#input.getValue();
50
+ }
51
+ setValue(v: string): void {
52
+ this.#input.setValue(v);
53
+ }
54
+
55
+ handleInput(data: string): void {
56
+ this.#input.handleInput(data);
57
+ }
58
+ invalidate(): void {
59
+ this.#input.invalidate();
60
+ }
61
+
62
+ render(width: number): readonly string[] {
63
+ const val = this.#input.getValue();
64
+ const display = this.#masked ? "*".repeat(Math.min(val.length, 40)) : val;
65
+ const prefix = theme.fg("accent", "● ");
66
+ const label = theme.bold(this.#label);
67
+ const sep = theme.fg("dim", ": ");
68
+ const field = this.#input.focused
69
+ ? theme.fg("muted", display + "█")
70
+ : theme.fg("dim", display || "(empty)");
71
+ return [prefix + label + sep + field];
72
+ }
73
+ }
74
+
75
+ // ─── State machine ─────────────────────────────────────────────────
76
+ type ByokState = "input" | "detecting" | "success" | "error";
77
+
78
+ // ─── BYOK Tab ───────────────────────────────────────────────────────
79
+ export class ByokSetupTab implements SetupTab {
80
+ readonly id = "byok";
81
+ readonly label = "BYOK";
82
+ get modal(): boolean { return this.#detecting; }
83
+
84
+ #baseUrl: FieldInput;
85
+ #apiKey: FieldInput;
86
+ #apiTypeIndex = 0;
87
+ #fields: FieldInput[];
88
+ #focusIndex = 0; // 0=baseUrl, 1=apiKey, 2=apiType
89
+ #state: ByokState = "input";
90
+ #statusLines: string[] = [];
91
+ #modelCount = 0;
92
+ #disposed = false;
93
+ #detecting = false;
94
+
95
+ constructor(private readonly host: SetupSceneHost) {
96
+ this.#baseUrl = new FieldInput("Base URL");
97
+ this.#apiKey = new FieldInput("API Key", true);
98
+ this.#fields = [this.#baseUrl, this.#apiKey];
99
+ this.#baseUrl.setValue("https://api.openai.com/v1");
100
+ this.#focusInput();
101
+ }
102
+
103
+ onActivate(): void {
104
+ this.#focusInput();
105
+ this.host.requestRender();
106
+ }
107
+
108
+ handleInput(data: string): void {
109
+ if (this.#state === "detecting") return; // block input during detection
110
+
111
+ // Escape key — finish/exit tab
112
+ if (data === "\x1b" || data === "\x1b\x1b") {
113
+ this.host.finish("skipped");
114
+ return;
115
+ }
116
+
117
+ if (data === "\x1b[A" || data === "\x1b[B") {
118
+ // Up/Down in api type selector
119
+ if (this.#focusIndex === 2) {
120
+ const dir = data === "\x1b[A" ? -1 : 1;
121
+ this.#apiTypeIndex = (this.#apiTypeIndex + dir + API_TYPE_OPTIONS.length) % API_TYPE_OPTIONS.length;
122
+ this.host.requestRender();
123
+ return;
124
+ }
125
+ }
126
+
127
+ if (data === "\t" || (data === "\x1b[B" && this.#focusIndex < 2)) {
128
+ this.#focusIndex = Math.min(2, this.#focusIndex + 1);
129
+ this.#focusInput();
130
+ this.host.requestRender();
131
+ return;
132
+ }
133
+
134
+ if (data === "\x1b[Z") { // Shift+Tab
135
+ this.#focusIndex = Math.max(0, this.#focusIndex - 1);
136
+ this.#focusInput();
137
+ this.host.requestRender();
138
+ return;
139
+ }
140
+
141
+ if (data === "\r" || data === "\n") {
142
+ if (this.#focusIndex < 2) {
143
+ // Move to next field on Enter
144
+ this.#focusIndex = Math.min(2, this.#focusIndex + 1);
145
+ this.#focusInput();
146
+ } else {
147
+ // Submit on Enter from api type
148
+ void this.#submit();
149
+ }
150
+ this.host.requestRender();
151
+ return;
152
+ }
153
+
154
+ // Forward to focused input field
155
+ if (this.#focusIndex < 2) {
156
+ this.#fields[this.#focusIndex].handleInput(data);
157
+ }
158
+ this.host.requestRender();
159
+ }
160
+
161
+ routeMouse(_event: SgrMouseEvent, _line: number, _col: number): void {
162
+ // No mouse routing needed for simple fields
163
+ }
164
+
165
+ invalidate(): void {
166
+ for (const f of this.#fields) f.invalidate();
167
+ }
168
+
169
+ dispose(): void {
170
+ this.#disposed = true;
171
+ }
172
+
173
+ render(width: number): readonly string[] {
174
+ const lines: string[] = [];
175
+ lines.push(theme.fg("muted", "Enter your provider details. Tab between fields, Enter to connect."));
176
+ lines.push(theme.fg("dim", "⚠ API Key stored in agent config directory"));
177
+ lines.push("");
178
+
179
+ // Base URL field
180
+ lines.push(...this.#baseUrl.render(width));
181
+
182
+ // API Key field
183
+ lines.push(...this.#apiKey.render(width));
184
+
185
+ // API Type selector
186
+ const selected = API_TYPE_OPTIONS[this.#apiTypeIndex];
187
+ const prefix = theme.fg("accent", "● ");
188
+ const label = theme.bold("API Type");
189
+ const sep = theme.fg("dim", ": ");
190
+ const value = this.#focusIndex === 2
191
+ ? theme.fg("muted", `◀ ${selected.label} ▶`)
192
+ : theme.fg("dim", selected.label);
193
+ const hint = this.#focusIndex === 2
194
+ ? theme.fg("dim", " ↑↓ to change")
195
+ : "";
196
+ lines.push(prefix + label + sep + value + hint);
197
+
198
+ // Status lines
199
+ if (this.#statusLines.length > 0) {
200
+ lines.push("");
201
+ lines.push(...this.#statusLines);
202
+ }
203
+
204
+ return lines;
205
+ }
206
+
207
+ #focusInput(): void {
208
+ for (const f of this.#fields) f.focused = false;
209
+ if (this.#focusIndex < 2) {
210
+ this.#fields[this.#focusIndex].focused = true;
211
+ this.host.setFocus(this.#fields[this.#focusIndex]);
212
+ } else {
213
+ this.host.setFocus(null);
214
+ }
215
+ }
216
+
217
+ async #submit(): Promise<void> {
218
+ const baseUrl = this.#baseUrl.getValue().trim().replace(/\/+$/, "");
219
+ const apiKey = this.#apiKey.getValue().trim();
220
+ const apiType = API_TYPE_OPTIONS[this.#apiTypeIndex].value as ApiType;
221
+
222
+ // Validate
223
+ if (!baseUrl) {
224
+ this.#statusLines = [theme.fg("error", `${theme.status.error} Base URL is required`)];
225
+ this.host.requestRender();
226
+ return;
227
+ }
228
+
229
+ // Determine auth mode — no API key + localhost = auth "none"
230
+ const isNoAuth = !apiKey;
231
+ if (!isNoAuth || baseUrl.includes("localhost") || baseUrl.includes("127.0.0.1")) {
232
+ // proceed
233
+ } else if (!apiKey && !baseUrl.includes("localhost")) {
234
+ this.#statusLines = [theme.fg("error", `${theme.status.error} API Key is required (or use localhost for local providers)`)];
235
+ this.host.requestRender();
236
+ return;
237
+ }
238
+
239
+ this.#state = "detecting";
240
+ this.#detecting = true;
241
+ this.#statusLines = [theme.fg("dim", "Detecting models…")];
242
+ this.host.requestRender();
243
+
244
+ try {
245
+ // Auto-detect models for OpenAI-compatible providers
246
+ const models = (!isNoAuth && (apiType === "openai-completions" || apiType === "openai-responses"))
247
+ ? await this.#detectModels(baseUrl, apiKey)
248
+ : [];
249
+
250
+ this.#modelCount = models.length;
251
+
252
+ // Write models.yml
253
+ await this.#saveProvider(baseUrl, apiKey, apiType, models, isNoAuth);
254
+
255
+ this.#state = "success";
256
+ this.#detecting = false;
257
+ const modelInfo = models.length > 0
258
+ ? `${models.length} model${models.length !== 1 ? "s" : ""} detected`
259
+ : "connected (manual model config needed)";
260
+ this.#statusLines = [
261
+ theme.fg("success", `${theme.status.success} ${modelInfo}`),
262
+ theme.fg("dim", `Provider saved to ${path.join(getAgentDir(), "models.yml")}`),
263
+ ];
264
+ } catch (err) {
265
+ this.#state = "error";
266
+ this.#detecting = false;
267
+ const msg = err instanceof Error ? err.message : String(err);
268
+ this.#statusLines = [
269
+ theme.fg("error", `${theme.status.error} ${msg}`),
270
+ theme.fg("dim", "Check your Base URL and API Key, then try again."),
271
+ ];
272
+ }
273
+
274
+ this.host.requestRender();
275
+ }
276
+
277
+ async #detectModels(baseUrl: string, apiKey: string): Promise<string[]> {
278
+ const url = `${baseUrl}/models`;
279
+ const headers: Record<string, string> = {};
280
+ if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
281
+
282
+ const response = await fetch(url, { headers, signal: AbortSignal.timeout(10_000) });
283
+ if (!response.ok) {
284
+ if (response.status === 401 || response.status === 403) {
285
+ throw new Error("API Key rejected — check your credentials");
286
+ }
287
+ throw new Error(`Provider returned HTTP ${response.status}`);
288
+ }
289
+
290
+ const data = await response.json() as { data?: { id: string }[] };
291
+ if (!data.data || !Array.isArray(data.data)) {
292
+ return [];
293
+ }
294
+ return data.data.map((m) => m.id).filter(Boolean).sort();
295
+ }
296
+
297
+ async #saveProvider(baseUrl: string, apiKey: string, apiType: ApiType, models: string[], isNoAuth = false): Promise<void> {
298
+ const agentDir = getAgentDir();
299
+ const configPath = path.join(agentDir, "models.yml");
300
+
301
+ // Read existing config
302
+ let existing: Record<string, unknown> = {};
303
+ if (fs.existsSync(configPath)) {
304
+ try {
305
+ const content = fs.readFileSync(configPath, "utf-8");
306
+ existing = YAML.parse(content) as Record<string, unknown> ?? {};
307
+ } catch {
308
+ existing = {};
309
+ }
310
+ }
311
+
312
+ const providers = (existing.providers ?? {}) as Record<string, unknown>;
313
+
314
+ // Generate provider name from base URL
315
+ const providerName = this.#deriveProviderName(baseUrl);
316
+
317
+ const providerConfig: Record<string, unknown> = {
318
+ baseUrl,
319
+ api: apiType,
320
+ auth: isNoAuth ? "none" : "apiKey",
321
+ };
322
+ if (apiKey) providerConfig.apiKey = apiKey;
323
+
324
+ if (models.length > 0) {
325
+ providerConfig.models = models.map((id) => ({
326
+ id,
327
+ api: apiType,
328
+ contextWindow: 128000,
329
+ supportsTools: true,
330
+ }));
331
+ }
332
+
333
+ providers[providerName] = providerConfig;
334
+ existing.providers = providers;
335
+
336
+ // Ensure directory exists
337
+ if (!fs.existsSync(agentDir)) {
338
+ fs.mkdirSync(agentDir, { recursive: true });
339
+ }
340
+
341
+ fs.writeFileSync(configPath, YAML.stringify(existing), "utf-8");
342
+ logger.info(`BYOK: provider "${providerName}" saved to ${configPath}`);
343
+ }
344
+
345
+ #deriveProviderName(baseUrl: string): string {
346
+ try {
347
+ const url = new URL(baseUrl);
348
+ const host = url.hostname.replace(/^api\./, "").replace(/\./g, "-");
349
+ return host || "custom-provider";
350
+ } catch {
351
+ return "custom-provider";
352
+ }
353
+ }
354
+ }