@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.
- package/CHANGELOG.md +8 -0
- package/README.md +63 -24
- package/bin/snsagent.js +3 -41
- package/dist/cli.js +3267 -3288
- package/package.json +4 -4
- package/src/adapters/telegram/handler.ts +1 -1
- package/src/agents/ensemble.ts +3 -4
- package/src/agents/executor.ts +129 -0
- package/src/async/notifier.ts +1 -1
- package/src/cli/config-cli.ts +1 -1
- package/src/cli/entry.ts +2 -2
- package/src/cli/gallery-fixtures/misc.ts +3 -3
- package/src/cli/grep-cli.ts +3 -3
- package/src/cli/grievances-cli.ts +2 -2
- package/src/cli/index.ts +186 -31
- package/src/cli/plugin-cli.ts +5 -5
- package/src/cli/setup-cli.ts +3 -3
- package/src/cli/setup-model-picker.ts +1 -1
- package/src/cli/shell-cli.ts +1 -1
- package/src/cli/ssh-cli.ts +1 -1
- package/src/cli/stats-cli.ts +1 -1
- package/src/cli/tiny-models-cli.ts +3 -3
- package/src/cli/ttsr-cli.ts +2 -2
- package/src/cli/update-cli.ts +3 -3
- package/src/cli/usage-cli.ts +1 -1
- package/src/cli-commands.ts +6 -6
- package/src/commands/acp.ts +2 -2
- package/src/commands/chat.ts +3 -3
- package/src/commands/install.ts +5 -5
- package/src/commands/say.ts +4 -4
- package/src/commands/setup.ts +1 -1
- package/src/config/settings-schema.ts +1 -1
- package/src/config/sns-config.ts +1 -1
- package/src/extensibility/plugins/loader.ts +1 -1
- package/src/extensibility/plugins/manager.ts +2 -2
- package/src/hindsight/client.ts +1 -1
- package/src/internal-urls/docs-index.generated.txt +2 -0
- package/src/main.ts +4 -0
- package/src/modes/acp/acp-agent.ts +3 -3
- package/src/modes/components/plugin-settings.ts +2 -2
- package/src/modes/components/welcome.ts +66 -160
- package/src/modes/interactive-mode.ts +2 -2
- package/src/modes/print-mode.ts +2 -2
- package/src/modes/setup-version.ts +1 -1
- package/src/modes/setup-wizard/scenes/byok-setup.ts +354 -0
- package/src/modes/setup-wizard/scenes/outro.ts +2 -2
- package/src/modes/setup-wizard/scenes/providers.ts +6 -5
- package/src/modes/setup-wizard/scenes/splash.ts +3 -3
- package/src/modes/setup-wizard/wizard-overlay.ts +2 -2
- package/src/modes/theme/dark.json +21 -20
- package/src/modes/theme/light.json +30 -29
- package/src/modes/utils/ui-helpers.ts +1 -1
- package/src/slash-commands/builtin-registry.ts +6 -3
- package/src/stt/recorder.ts +3 -3
- package/src/tts/player.ts +1 -1
- package/src/tts/tts-client.ts +1 -1
- package/src/tui/chat-blocks.ts +4 -4
- package/src/tui/chat-ui.ts +3 -3
- package/src/tui/code-cell.ts +3 -3
- package/src/tui/command-palette.ts +3 -3
- package/src/tui/splash.ts +12 -18
- package/src/ui/banner.ts +51 -66
- package/src/ui/chat-prompt.ts +2 -2
- package/src/ui/colors.ts +17 -14
- package/src/ui/error-display.ts +2 -2
- package/src/ui/gradient.ts +3 -3
- package/src/ui/memory-toast.ts +5 -5
- 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 = "
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
|
295
|
-
|
|
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
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
//
|
|
392
|
-
lines.push(
|
|
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
|
|
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
|
-
[
|
|
470
|
-
[
|
|
471
|
-
[
|
|
472
|
-
[
|
|
473
|
-
[
|
|
375
|
+
[249, 115, 22], // #F97316 — orange-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 = [
|
|
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(
|
|
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(
|
|
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 `
|
|
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 `
|
|
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 →
|
package/src/modes/print-mode.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Print mode (single-shot): Send prompts, output result, exit.
|
|
3
3
|
*
|
|
4
4
|
* Used for:
|
|
5
|
-
* - `
|
|
6
|
-
* - `
|
|
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";
|
|
@@ -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
|
+
}
|