@kinqs/brainrouter-cli 0.3.6 → 0.3.8
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/README.md +29 -52
- package/agents/architect.json +18 -0
- package/agents/explorer.json +18 -0
- package/agents/reviewer.json +18 -0
- package/agents/verifier.json +18 -0
- package/agents/worker.json +18 -0
- package/changelog/0.2.0.md +15 -0
- package/changelog/0.3.0.md +20 -0
- package/changelog/0.3.1.md +22 -0
- package/changelog/0.3.2.md +15 -0
- package/changelog/0.3.3.md +19 -0
- package/changelog/0.3.4.md +20 -0
- package/changelog/0.3.5.md +9 -0
- package/changelog/0.3.6.md +9 -0
- package/changelog/0.3.7.md +20 -0
- package/changelog/0.3.8.md +30 -0
- package/changelog/README.md +41 -0
- package/dist/agent/agent.d.ts +34 -1
- package/dist/agent/agent.js +372 -79
- package/dist/agent/toolCallRecovery.d.ts +57 -0
- package/dist/agent/toolCallRecovery.js +130 -0
- package/dist/agent/toolSafety.d.ts +17 -0
- package/dist/agent/toolSafety.js +102 -0
- package/dist/cli/banner.d.ts +20 -0
- package/dist/cli/banner.js +47 -14
- package/dist/cli/cliPrompt.d.ts +40 -3
- package/dist/cli/cliPrompt.js +117 -25
- package/dist/cli/commands/_context.d.ts +3 -1
- package/dist/cli/commands/_helpers.d.ts +1 -1
- package/dist/cli/commands/config.d.ts +46 -0
- package/dist/cli/commands/config.js +1042 -0
- package/dist/cli/commands/init.d.ts +20 -0
- package/dist/cli/commands/init.js +64 -0
- package/dist/cli/commands/login.d.ts +13 -0
- package/dist/cli/commands/login.js +179 -0
- package/dist/cli/commands/mcp.d.ts +13 -11
- package/dist/cli/commands/mcp.js +261 -74
- package/dist/cli/commands/mcpInstall.d.ts +20 -0
- package/dist/cli/commands/mcpInstall.js +87 -0
- package/dist/cli/commands/orchestration.js +51 -0
- package/dist/cli/commands/releaseNotes.d.ts +24 -0
- package/dist/cli/commands/releaseNotes.js +109 -0
- package/dist/cli/commands/schedule.d.ts +18 -0
- package/dist/cli/commands/schedule.js +189 -0
- package/dist/cli/commands/ui.js +119 -60
- package/dist/cli/commands/workflow.d.ts +2 -0
- package/dist/cli/commands/workflow.js +54 -8
- package/dist/cli/ink/ChatApp.d.ts +206 -0
- package/dist/cli/ink/ChatApp.js +493 -0
- package/dist/cli/ink/Frame.d.ts +26 -0
- package/dist/cli/ink/Frame.js +5 -0
- package/dist/cli/ink/Picker.d.ts +71 -0
- package/dist/cli/ink/Picker.js +168 -0
- package/dist/cli/ink/SlashPalette.d.ts +51 -0
- package/dist/cli/ink/SlashPalette.js +136 -0
- package/dist/cli/ink/TextField.d.ts +34 -0
- package/dist/cli/ink/TextField.js +47 -0
- package/dist/cli/ink/WizardApp.d.ts +7 -0
- package/dist/cli/ink/WizardApp.js +422 -0
- package/dist/cli/ink/ambientChat.d.ts +34 -0
- package/dist/cli/ink/ambientChat.js +7 -0
- package/dist/cli/ink/consoleCapture.d.ts +11 -0
- package/dist/cli/ink/consoleCapture.js +33 -0
- package/dist/cli/ink/markdownRender.d.ts +41 -0
- package/dist/cli/ink/markdownRender.js +278 -0
- package/dist/cli/ink/renderWithResizeClear.d.ts +14 -0
- package/dist/cli/ink/renderWithResizeClear.js +33 -0
- package/dist/cli/ink/runChat.d.ts +34 -0
- package/dist/cli/ink/runChat.js +682 -0
- package/dist/cli/ink/runPicker.d.ts +31 -0
- package/dist/cli/ink/runPicker.js +139 -0
- package/dist/cli/ink/runSlashPalette.d.ts +23 -0
- package/dist/cli/ink/runSlashPalette.js +33 -0
- package/dist/cli/ink/runWizard.d.ts +22 -0
- package/dist/cli/ink/runWizard.js +133 -0
- package/dist/cli/ink/stdinHandoff.d.ts +51 -0
- package/dist/cli/ink/stdinHandoff.js +78 -0
- package/dist/cli/ink/toolFormat.d.ts +75 -0
- package/dist/cli/ink/toolFormat.js +206 -0
- package/dist/cli/ink/useTerminalSize.d.ts +35 -0
- package/dist/cli/ink/useTerminalSize.js +26 -0
- package/dist/cli/repl.d.ts +25 -3
- package/dist/cli/repl.js +52 -714
- package/dist/cli/slashSuggest.d.ts +32 -0
- package/dist/cli/slashSuggest.js +146 -0
- package/dist/cli/wizard/modelsApi.d.ts +72 -0
- package/dist/cli/wizard/modelsApi.js +166 -0
- package/dist/cli/wizard/picker.d.ts +202 -0
- package/dist/cli/wizard/picker.js +547 -0
- package/dist/cli/wizard/providers.d.ts +86 -0
- package/dist/cli/wizard/providers.js +190 -0
- package/dist/cli/wizard/runner.d.ts +13 -0
- package/dist/cli/wizard/runner.js +488 -0
- package/dist/cli/wizard/types.d.ts +122 -0
- package/dist/cli/wizard/types.js +109 -0
- package/dist/config/config.d.ts +13 -1
- package/dist/config/config.js +45 -3
- package/dist/index.js +157 -206
- package/dist/memory/briefing.d.ts +1 -1
- package/dist/memory/briefing.js +4 -4
- package/dist/memory/consolidation.d.ts +1 -1
- package/dist/orchestration/agentRegistry.d.ts +36 -0
- package/dist/orchestration/agentRegistry.js +64 -0
- package/dist/orchestration/orchestrator.d.ts +7 -0
- package/dist/orchestration/orchestrator.js +2 -0
- package/dist/orchestration/tools.d.ts +105 -3
- package/dist/orchestration/tools.js +167 -8
- package/dist/prompt/skillCatalog.d.ts +11 -0
- package/dist/prompt/skillCatalog.js +134 -0
- package/dist/prompt/skillRunner.d.ts +2 -2
- package/dist/prompt/skillRunner.js +2 -31
- package/dist/prompt/systemPrompt.js +7 -2
- package/dist/runtime/anthropicAdapter.d.ts +100 -0
- package/dist/runtime/anthropicAdapter.js +293 -0
- package/dist/runtime/cronParser.d.ts +23 -0
- package/dist/runtime/cronParser.js +122 -0
- package/dist/runtime/mcpClient.js +14 -11
- package/dist/runtime/mcpPool.d.ts +170 -0
- package/dist/runtime/mcpPool.js +442 -0
- package/dist/runtime/mcpUtils.d.ts +17 -1
- package/dist/runtime/mcpUtils.js +23 -0
- package/dist/runtime/scheduleTicker.d.ts +33 -0
- package/dist/runtime/scheduleTicker.js +99 -0
- package/dist/runtime/vendorSnippets.d.ts +45 -0
- package/dist/runtime/vendorSnippets.js +153 -0
- package/dist/state/scheduleStore.d.ts +37 -0
- package/dist/state/scheduleStore.js +64 -0
- package/package.json +14 -5
- package/.env.example +0 -116
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type readline from 'node:readline';
|
|
2
|
+
import { type Theme } from './theme.js';
|
|
3
|
+
export interface SlashCommand {
|
|
4
|
+
/** "/help", "/config", etc. — the literal token the user types. */
|
|
5
|
+
cmd: string;
|
|
6
|
+
/** One-line description shown after the em-dash. */
|
|
7
|
+
description: string;
|
|
8
|
+
}
|
|
9
|
+
export interface SlashSuggestController {
|
|
10
|
+
/** Call after every readline keypress to refresh the popup. */
|
|
11
|
+
onKey(): void;
|
|
12
|
+
/** Force-hide the popup (called on submit / cancel). */
|
|
13
|
+
hide(): void;
|
|
14
|
+
/** Returns true while the popup is visible. */
|
|
15
|
+
isVisible(): boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface SlashSuggestOpts {
|
|
18
|
+
rl: readline.Interface;
|
|
19
|
+
commands: SlashCommand[];
|
|
20
|
+
theme?: Theme;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Two-tier rank for a single command against a query. Lower wins.
|
|
24
|
+
*
|
|
25
|
+
* 0 command starts with query (after the leading /)
|
|
26
|
+
* 1 command contains query
|
|
27
|
+
* 2 description contains query
|
|
28
|
+
* 3 no match
|
|
29
|
+
*/
|
|
30
|
+
export declare function scoreSlashCommand(cmd: SlashCommand, query: string): number;
|
|
31
|
+
export declare function filterAndSort(commands: SlashCommand[], query: string): SlashCommand[];
|
|
32
|
+
export declare function createSlashSuggest(opts: SlashSuggestOpts): SlashSuggestController;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { buildTheme } from './theme.js';
|
|
2
|
+
/**
|
|
3
|
+
* 0.3.7 — slash-command autosuggest popup.
|
|
4
|
+
*
|
|
5
|
+
* Renders a filtered list of slash commands BELOW the prompt as the
|
|
6
|
+
* user types. Hides automatically when the input no longer starts
|
|
7
|
+
* with `/`. Updates on every keystroke.
|
|
8
|
+
*
|
|
9
|
+
* Pattern lineage:
|
|
10
|
+
* - Two-tier ranking (exact → prefix → includes, lower wins, stable
|
|
11
|
+
* secondary sort by original index) lifted from
|
|
12
|
+
* `openSrc/grok-cli/src/ui/slash-menu.ts:44-64`.
|
|
13
|
+
* - Popup height cap (max ~6 visible) from
|
|
14
|
+
* `openSrc/codex/codex-rs/tui/src/bottom_pane/popup_consts.rs`
|
|
15
|
+
* and the Claude Code CHANGELOG note (line 378) explicitly
|
|
16
|
+
* capping the popup at "3-5 visible commands instead of scaling
|
|
17
|
+
* with terminal height."
|
|
18
|
+
*
|
|
19
|
+
* Render strategy (kept simple — no scroll-region tricks needed):
|
|
20
|
+
*
|
|
21
|
+
* - On every keystroke we check `rl.line`. If it starts with `/`,
|
|
22
|
+
* compute the filtered list and (re)render the popup BELOW the
|
|
23
|
+
* current prompt line. The cursor is saved before the popup
|
|
24
|
+
* write and restored after — readline's prompt position stays
|
|
25
|
+
* untouched.
|
|
26
|
+
* - If the popup was previously visible AND should now be hidden
|
|
27
|
+
* (input no longer starts with `/`, or no matches), erase the
|
|
28
|
+
* popup region with `\x1b[J` from the position one line below
|
|
29
|
+
* the prompt.
|
|
30
|
+
*
|
|
31
|
+
* The function returns a controller you can wire into the REPL —
|
|
32
|
+
* call `controller.onKey()` after each readline keypress to refresh.
|
|
33
|
+
* Call `controller.hide()` on submit / cancel.
|
|
34
|
+
*/
|
|
35
|
+
const MAX_VISIBLE = 6;
|
|
36
|
+
/**
|
|
37
|
+
* Two-tier rank for a single command against a query. Lower wins.
|
|
38
|
+
*
|
|
39
|
+
* 0 command starts with query (after the leading /)
|
|
40
|
+
* 1 command contains query
|
|
41
|
+
* 2 description contains query
|
|
42
|
+
* 3 no match
|
|
43
|
+
*/
|
|
44
|
+
export function scoreSlashCommand(cmd, query) {
|
|
45
|
+
if (!query)
|
|
46
|
+
return 0;
|
|
47
|
+
const q = query.toLowerCase();
|
|
48
|
+
const cmdBody = cmd.cmd.slice(1).toLowerCase(); // skip the leading /
|
|
49
|
+
if (cmdBody.startsWith(q))
|
|
50
|
+
return 0;
|
|
51
|
+
if (cmdBody.includes(q))
|
|
52
|
+
return 1;
|
|
53
|
+
if (cmd.description.toLowerCase().includes(q))
|
|
54
|
+
return 2;
|
|
55
|
+
return 3;
|
|
56
|
+
}
|
|
57
|
+
export function filterAndSort(commands, query) {
|
|
58
|
+
if (!query)
|
|
59
|
+
return commands.slice(0, MAX_VISIBLE);
|
|
60
|
+
const scored = commands
|
|
61
|
+
.map((c, i) => ({ c, i, s: scoreSlashCommand(c, query) }))
|
|
62
|
+
.filter((x) => x.s < 3);
|
|
63
|
+
scored.sort((a, b) => (a.s - b.s) || (a.i - b.i)); // stable by original index
|
|
64
|
+
return scored.slice(0, MAX_VISIBLE).map((x) => x.c);
|
|
65
|
+
}
|
|
66
|
+
export function createSlashSuggest(opts) {
|
|
67
|
+
const theme = opts.theme ?? buildTheme('dark');
|
|
68
|
+
const stdout = process.stdout;
|
|
69
|
+
let lastVisible = false;
|
|
70
|
+
let lastHeight = 0;
|
|
71
|
+
let lastQuery = '';
|
|
72
|
+
const readLine = () => {
|
|
73
|
+
const rl = opts.rl;
|
|
74
|
+
return rl.line ?? '';
|
|
75
|
+
};
|
|
76
|
+
const erase = () => {
|
|
77
|
+
if (!lastVisible)
|
|
78
|
+
return;
|
|
79
|
+
// Save cursor → move to col 0 of next line → erase from cursor to
|
|
80
|
+
// end of screen → restore cursor. `\x1b[s` and `\x1b[u` are the
|
|
81
|
+
// SCO sequences; widely supported.
|
|
82
|
+
stdout.write('\x1b7'); // DECSC — save cursor + attrs (more reliable than \x1b[s in xterm)
|
|
83
|
+
stdout.write('\n'); // move down one
|
|
84
|
+
stdout.write('\r'); // col 0
|
|
85
|
+
stdout.write('\x1b[J'); // erase from here to end of screen
|
|
86
|
+
stdout.write('\x1b8'); // DECRC — restore cursor
|
|
87
|
+
lastVisible = false;
|
|
88
|
+
lastHeight = 0;
|
|
89
|
+
};
|
|
90
|
+
const render = (matches) => {
|
|
91
|
+
// Pad command column for alignment.
|
|
92
|
+
const cmdWidth = Math.max(...matches.map((m) => m.cmd.length));
|
|
93
|
+
const lines = matches.map((m, idx) => {
|
|
94
|
+
const cmdPart = theme.heading(m.cmd.padEnd(cmdWidth, ' '));
|
|
95
|
+
const arrow = theme.dim('—');
|
|
96
|
+
const desc = theme.muted(m.description);
|
|
97
|
+
// First match gets a `›` marker; others a space.
|
|
98
|
+
const marker = idx === 0 ? theme.primary('›') : ' ';
|
|
99
|
+
return ` ${marker} ${cmdPart} ${arrow} ${desc}`;
|
|
100
|
+
});
|
|
101
|
+
// Hint line under the suggestions.
|
|
102
|
+
lines.push(theme.dim(' Tab to autocomplete · Enter to submit · type to filter'));
|
|
103
|
+
// First, erase any previous popup.
|
|
104
|
+
if (lastVisible) {
|
|
105
|
+
stdout.write('\x1b7');
|
|
106
|
+
stdout.write('\n\r\x1b[J');
|
|
107
|
+
stdout.write('\x1b8');
|
|
108
|
+
}
|
|
109
|
+
// Now draw the new popup below the prompt:
|
|
110
|
+
stdout.write('\x1b7');
|
|
111
|
+
stdout.write('\n'); // step down to a new line
|
|
112
|
+
stdout.write('\r');
|
|
113
|
+
for (let i = 0; i < lines.length; i++) {
|
|
114
|
+
stdout.write(lines[i]);
|
|
115
|
+
if (i < lines.length - 1)
|
|
116
|
+
stdout.write('\n\r');
|
|
117
|
+
}
|
|
118
|
+
stdout.write('\x1b8'); // restore cursor to the prompt input position
|
|
119
|
+
lastVisible = true;
|
|
120
|
+
lastHeight = lines.length;
|
|
121
|
+
};
|
|
122
|
+
return {
|
|
123
|
+
onKey: () => {
|
|
124
|
+
const line = readLine();
|
|
125
|
+
if (!line.startsWith('/')) {
|
|
126
|
+
if (lastVisible)
|
|
127
|
+
erase();
|
|
128
|
+
lastQuery = '';
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const query = line.slice(1);
|
|
132
|
+
if (query === lastQuery && lastVisible)
|
|
133
|
+
return; // no-op
|
|
134
|
+
lastQuery = query;
|
|
135
|
+
const matches = filterAndSort(opts.commands, query);
|
|
136
|
+
if (matches.length === 0) {
|
|
137
|
+
if (lastVisible)
|
|
138
|
+
erase();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
render(matches);
|
|
142
|
+
},
|
|
143
|
+
hide: () => { erase(); lastQuery = ''; },
|
|
144
|
+
isVisible: () => lastVisible,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Theme } from '../theme.js';
|
|
2
|
+
import type { ProviderEntry } from './providers.js';
|
|
3
|
+
/**
|
|
4
|
+
* Fetch the live model list from an OpenAI-compatible `/v1/models`
|
|
5
|
+
* endpoint and return them as a sorted, deduped string array.
|
|
6
|
+
*
|
|
7
|
+
* Every OpenAI-compatible server we ship a provider entry for honours
|
|
8
|
+
* `GET <endpoint>/models` (OpenAI, DeepSeek, OpenRouter, LM Studio,
|
|
9
|
+
* Ollama, vLLM, gateway proxies like LiteLLM). The response shape is
|
|
10
|
+
* `{ object: 'list', data: [{ id: string, owned_by?: string, ... }] }`.
|
|
11
|
+
*
|
|
12
|
+
* We derive the `/models` URL from the provider's chat endpoint by
|
|
13
|
+
* stripping the trailing `/chat/completions` path segment. That works
|
|
14
|
+
* for every endpoint shape we care about:
|
|
15
|
+
*
|
|
16
|
+
* https://api.openai.com/v1/chat/completions → /v1/models
|
|
17
|
+
* http://localhost:1234/v1/chat/completions → /v1/models
|
|
18
|
+
* https://openrouter.ai/api/v1/chat/completions → /api/v1/models
|
|
19
|
+
*
|
|
20
|
+
* 5-second timeout — if the call hangs or fails, the wizard falls
|
|
21
|
+
* back to the provider's curated static catalog so users on a slow
|
|
22
|
+
* link / behind a captive portal aren't blocked.
|
|
23
|
+
*/
|
|
24
|
+
export declare function fetchOpenAiCompatibleModels(provider: ProviderEntry, apiKey: string, endpointOverride?: string): Promise<{
|
|
25
|
+
ok: true;
|
|
26
|
+
models: string[];
|
|
27
|
+
} | {
|
|
28
|
+
ok: false;
|
|
29
|
+
error: string;
|
|
30
|
+
}>;
|
|
31
|
+
export declare function deriveModelsUrl(chatEndpoint: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Open the model picker — live `/v1/models` fetch with the provider's
|
|
34
|
+
* static catalog as the offline fallback. Returns the selected model id,
|
|
35
|
+
* or `undefined` when the user cancels.
|
|
36
|
+
*
|
|
37
|
+
* Used by both the onboarding wizard's Model step
|
|
38
|
+
* (`cli/wizard/runner.ts → runModelStep`) and the in-REPL `/model`
|
|
39
|
+
* quick-swap command (`cli/commands/ui.ts → /model`). Keeping a single
|
|
40
|
+
* implementation means a future enrichment (recently-used badge,
|
|
41
|
+
* cost-per-token hint, model-size group headers) lights up everywhere
|
|
42
|
+
* at once.
|
|
43
|
+
*
|
|
44
|
+
* `currentModel` (when supplied) defaults the picker cursor onto the
|
|
45
|
+
* currently active row — important for `/model` where the user almost
|
|
46
|
+
* always wants to confirm what's currently selected before changing.
|
|
47
|
+
*/
|
|
48
|
+
export interface SelectModelOptions {
|
|
49
|
+
theme: Theme;
|
|
50
|
+
provider: ProviderEntry;
|
|
51
|
+
apiKey: string;
|
|
52
|
+
/** Override the provider's default chat endpoint (custom-endpoint flow). */
|
|
53
|
+
endpointOverride?: string;
|
|
54
|
+
/** Active model id — cursor opens on this row when present. */
|
|
55
|
+
currentModel?: string;
|
|
56
|
+
/** Picker title (default: "Model"). */
|
|
57
|
+
title?: string;
|
|
58
|
+
/** Optional badge rendered next to the title. */
|
|
59
|
+
badge?: string;
|
|
60
|
+
/** Erase the picker frame after a selection (true for wizard, false for in-REPL). */
|
|
61
|
+
eraseOnClose?: boolean;
|
|
62
|
+
}
|
|
63
|
+
export interface SelectModelResult {
|
|
64
|
+
model: string;
|
|
65
|
+
/** Where the picker got its list from — surfaces in the success message. */
|
|
66
|
+
source: 'live' | 'static' | 'fallback';
|
|
67
|
+
/** Number of models the live call returned (0 when source !== 'live'). */
|
|
68
|
+
liveCount: number;
|
|
69
|
+
/** Live-call error message when source !== 'live' (omitted on live success). */
|
|
70
|
+
liveError?: string;
|
|
71
|
+
}
|
|
72
|
+
export declare function selectModel(opts: SelectModelOptions): Promise<SelectModelResult | undefined>;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { runPicker } from '../ink/runPicker.js';
|
|
2
|
+
/**
|
|
3
|
+
* Fetch the live model list from an OpenAI-compatible `/v1/models`
|
|
4
|
+
* endpoint and return them as a sorted, deduped string array.
|
|
5
|
+
*
|
|
6
|
+
* Every OpenAI-compatible server we ship a provider entry for honours
|
|
7
|
+
* `GET <endpoint>/models` (OpenAI, DeepSeek, OpenRouter, LM Studio,
|
|
8
|
+
* Ollama, vLLM, gateway proxies like LiteLLM). The response shape is
|
|
9
|
+
* `{ object: 'list', data: [{ id: string, owned_by?: string, ... }] }`.
|
|
10
|
+
*
|
|
11
|
+
* We derive the `/models` URL from the provider's chat endpoint by
|
|
12
|
+
* stripping the trailing `/chat/completions` path segment. That works
|
|
13
|
+
* for every endpoint shape we care about:
|
|
14
|
+
*
|
|
15
|
+
* https://api.openai.com/v1/chat/completions → /v1/models
|
|
16
|
+
* http://localhost:1234/v1/chat/completions → /v1/models
|
|
17
|
+
* https://openrouter.ai/api/v1/chat/completions → /api/v1/models
|
|
18
|
+
*
|
|
19
|
+
* 5-second timeout — if the call hangs or fails, the wizard falls
|
|
20
|
+
* back to the provider's curated static catalog so users on a slow
|
|
21
|
+
* link / behind a captive portal aren't blocked.
|
|
22
|
+
*/
|
|
23
|
+
export async function fetchOpenAiCompatibleModels(provider, apiKey, endpointOverride) {
|
|
24
|
+
const chatEndpoint = endpointOverride ?? provider.endpoint;
|
|
25
|
+
const modelsUrl = deriveModelsUrl(chatEndpoint);
|
|
26
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
27
|
+
// Cloud providers always want a Bearer header. Local endpoints (LM
|
|
28
|
+
// Studio, Ollama) accept anything OR nothing, but sending the key
|
|
29
|
+
// through doesn't hurt — they ignore it.
|
|
30
|
+
if (apiKey.trim().length > 0) {
|
|
31
|
+
headers['Authorization'] = `Bearer ${apiKey.trim()}`;
|
|
32
|
+
}
|
|
33
|
+
else if (provider.local) {
|
|
34
|
+
// Some local servers reject requests with NO Authorization header
|
|
35
|
+
// even though they don't validate the value. A literal "local"
|
|
36
|
+
// bearer is the convention for LM Studio / Ollama config snippets.
|
|
37
|
+
headers['Authorization'] = 'Bearer local';
|
|
38
|
+
}
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
const timeout = setTimeout(() => controller.abort(), 5_000);
|
|
41
|
+
try {
|
|
42
|
+
const res = await fetch(modelsUrl, {
|
|
43
|
+
method: 'GET',
|
|
44
|
+
headers,
|
|
45
|
+
signal: controller.signal,
|
|
46
|
+
});
|
|
47
|
+
if (!res.ok) {
|
|
48
|
+
const text = await res.text().catch(() => '');
|
|
49
|
+
return { ok: false, error: `HTTP ${res.status} ${res.statusText} — ${text.slice(0, 200)}` };
|
|
50
|
+
}
|
|
51
|
+
const data = await res.json();
|
|
52
|
+
const list = Array.isArray(data?.data) ? data.data : [];
|
|
53
|
+
const ids = list
|
|
54
|
+
.map((m) => (typeof m?.id === 'string' ? m.id : null))
|
|
55
|
+
.filter((s) => !!s);
|
|
56
|
+
if (ids.length === 0) {
|
|
57
|
+
return { ok: false, error: 'endpoint returned an empty model list' };
|
|
58
|
+
}
|
|
59
|
+
return { ok: true, models: dedupeAndSort(ids) };
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
if (err?.name === 'AbortError') {
|
|
63
|
+
return { ok: false, error: 'timed out after 5s' };
|
|
64
|
+
}
|
|
65
|
+
return { ok: false, error: String(err?.message ?? err) };
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
clearTimeout(timeout);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export function deriveModelsUrl(chatEndpoint) {
|
|
72
|
+
// Replace a trailing `/chat/completions` (with optional trailing
|
|
73
|
+
// slash) with `/models`. If the endpoint doesn't end in
|
|
74
|
+
// `/chat/completions` (already a base URL, custom path), append
|
|
75
|
+
// `/models` directly.
|
|
76
|
+
const trimmed = chatEndpoint.replace(/\/+$/, '');
|
|
77
|
+
if (trimmed.endsWith('/chat/completions')) {
|
|
78
|
+
return trimmed.slice(0, -'/chat/completions'.length) + '/models';
|
|
79
|
+
}
|
|
80
|
+
return trimmed + '/models';
|
|
81
|
+
}
|
|
82
|
+
function dedupeAndSort(ids) {
|
|
83
|
+
return Array.from(new Set(ids)).sort((a, b) => a.localeCompare(b));
|
|
84
|
+
}
|
|
85
|
+
export async function selectModel(opts) {
|
|
86
|
+
const { provider, apiKey, endpointOverride, currentModel, theme } = opts;
|
|
87
|
+
let modelsList = provider.models;
|
|
88
|
+
let source = 'static';
|
|
89
|
+
let liveCount = 0;
|
|
90
|
+
let liveError;
|
|
91
|
+
let subtitleHint = `Pick the chat model for ${provider.label}.`;
|
|
92
|
+
// Live fetch is gated on either having a key (cloud) or running local.
|
|
93
|
+
// Skipping the fetch entirely when neither is true avoids a guaranteed
|
|
94
|
+
// 401 / network error on the loading frame.
|
|
95
|
+
if (apiKey.trim().length > 0 || provider.local) {
|
|
96
|
+
const fetched = await fetchOpenAiCompatibleModels(provider, apiKey, endpointOverride);
|
|
97
|
+
if (fetched.ok) {
|
|
98
|
+
const live = fetched.models;
|
|
99
|
+
// Default model floats to the top so "(default)" stays in the
|
|
100
|
+
// natural-first position the user expects. Then the currently-
|
|
101
|
+
// active model floats next (cursor lands here below).
|
|
102
|
+
const reordered = floatToTop(live, [provider.defaultModel]);
|
|
103
|
+
modelsList = reordered;
|
|
104
|
+
source = 'live';
|
|
105
|
+
liveCount = live.length;
|
|
106
|
+
subtitleHint = `Pick a model — ${live.length} returned by ${provider.label}'s /v1/models endpoint. Use "Other" to type any name.`;
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
source = 'fallback';
|
|
110
|
+
liveError = fetched.error;
|
|
111
|
+
subtitleHint = `Pick a model. (Live list unavailable — ${fetched.error}. Showing curated short-list.) Use "Other" to type any name.`;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const finalList = modelsList.length > 0 ? modelsList : [provider.defaultModel];
|
|
115
|
+
const rows = finalList.map((m) => ({
|
|
116
|
+
id: m,
|
|
117
|
+
label: m,
|
|
118
|
+
value: m === currentModel ? 'current' :
|
|
119
|
+
m === provider.defaultModel ? 'default' : '',
|
|
120
|
+
}));
|
|
121
|
+
// Cursor priority: currently-active model > provider default > top.
|
|
122
|
+
let initialCursor = 0;
|
|
123
|
+
if (currentModel) {
|
|
124
|
+
const idx = finalList.indexOf(currentModel);
|
|
125
|
+
if (idx >= 0)
|
|
126
|
+
initialCursor = idx;
|
|
127
|
+
}
|
|
128
|
+
if (initialCursor === 0 && !currentModel) {
|
|
129
|
+
const idx = finalList.indexOf(provider.defaultModel);
|
|
130
|
+
if (idx >= 0)
|
|
131
|
+
initialCursor = idx;
|
|
132
|
+
}
|
|
133
|
+
const result = await runPicker({
|
|
134
|
+
theme,
|
|
135
|
+
title: opts.title ?? 'Model',
|
|
136
|
+
subtitle: subtitleHint,
|
|
137
|
+
badge: opts.badge,
|
|
138
|
+
rows,
|
|
139
|
+
initialCursor,
|
|
140
|
+
allowOther: true,
|
|
141
|
+
otherLabel: 'Other model',
|
|
142
|
+
otherDescription: 'Type any model name supported by this endpoint',
|
|
143
|
+
eraseOnClose: opts.eraseOnClose ?? false,
|
|
144
|
+
});
|
|
145
|
+
if (result.kind === 'cancelled')
|
|
146
|
+
return undefined;
|
|
147
|
+
const model = (result.kind === 'other' ? result.text.trim() : result.id) || provider.defaultModel;
|
|
148
|
+
return { model, source, liveCount, liveError };
|
|
149
|
+
}
|
|
150
|
+
function floatToTop(list, priority) {
|
|
151
|
+
const seen = new Set();
|
|
152
|
+
const front = [];
|
|
153
|
+
for (const p of priority) {
|
|
154
|
+
if (list.includes(p) && !seen.has(p)) {
|
|
155
|
+
front.push(p);
|
|
156
|
+
seen.add(p);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
for (const m of list) {
|
|
160
|
+
if (!seen.has(m)) {
|
|
161
|
+
front.push(m);
|
|
162
|
+
seen.add(m);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return front;
|
|
166
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { type Theme } from '../theme.js';
|
|
2
|
+
/**
|
|
3
|
+
* Internal picker primitive — purpose-built for the wizard / `/config` /
|
|
4
|
+
* `/login` flows.
|
|
5
|
+
*
|
|
6
|
+
* Distinct from `cliPrompt.ts:askChoice` (which backs the LLM-callable
|
|
7
|
+
* `ask_user_choice` tool and is intentionally constrained: 2–4 options,
|
|
8
|
+
* always-on synthetic "Other" row, error envelopes for the agent). This
|
|
9
|
+
* picker has no LLM-tool constraints — N options, optional "Other" row,
|
|
10
|
+
* optional free-text input, optional live-preview callback that returns
|
|
11
|
+
* preview ROWS (rendered INSIDE the picker's frame) instead of writing
|
|
12
|
+
* to stdout.
|
|
13
|
+
*
|
|
14
|
+
* Render contract (atomic frame):
|
|
15
|
+
*
|
|
16
|
+
* 1. Caller passes ALL chrome (title, subtitle, options, footer hint)
|
|
17
|
+
* as fields on `PickerView`. The picker computes the full frame
|
|
18
|
+
* string in one pass and writes it with one `stdout.write`.
|
|
19
|
+
* 2. The picker owns its rendered region for its lifetime. NO call
|
|
20
|
+
* site may write to stdout while the picker is active — preview
|
|
21
|
+
* lines are returned from `onCursorChange` as a string[] that the
|
|
22
|
+
* picker splices into its own frame. (Pattern lifted from
|
|
23
|
+
* `openSrc/codex/codex-rs/tui/src/theme_picker.rs` — preview never
|
|
24
|
+
* writes; the redraw owns the change.)
|
|
25
|
+
* 3. Redraw uses `\x1b[<N>F` (cursor up + col 0) + `\x1b[J` (erase to
|
|
26
|
+
* end of screen) to nuke the previous frame, then writes the new
|
|
27
|
+
* one. No `text + '\n'` off-by-one because we count the actual
|
|
28
|
+
* lines we'll write.
|
|
29
|
+
*
|
|
30
|
+
* Why a separate file (not just an extension of `cliPrompt.ts`)? The
|
|
31
|
+
* LLM-tool contract for `ask_user_choice` is explicit ("2-4 options
|
|
32
|
+
* with mutually exclusive labels, always an Other fallback"), and
|
|
33
|
+
* widening it would weaken the constraint the system prompt teaches
|
|
34
|
+
* the model to follow. Internal CLI flows have different requirements
|
|
35
|
+
* (7 providers in a list, no "Other" for theme picker, free-text-only
|
|
36
|
+
* for API-key entry). Keep them in separate primitives.
|
|
37
|
+
*/
|
|
38
|
+
export interface PickerRow {
|
|
39
|
+
/** Stable id used in the resolved result. */
|
|
40
|
+
id: string;
|
|
41
|
+
/** Human-readable left-aligned label. */
|
|
42
|
+
label: string;
|
|
43
|
+
/** Right-aligned value column (current setting, hint text, status). Optional. */
|
|
44
|
+
value?: string;
|
|
45
|
+
/** Sub-line shown muted under the label. Optional. */
|
|
46
|
+
description?: string;
|
|
47
|
+
/** When true, the row is shown but not selectable (separator-like). */
|
|
48
|
+
disabled?: boolean;
|
|
49
|
+
}
|
|
50
|
+
export interface PickFromListOptions {
|
|
51
|
+
/** Bold title rendered at the top of the frame (e.g. "Theme"). */
|
|
52
|
+
title: string;
|
|
53
|
+
/** Muted subtitle under the title (e.g. "Pick a color palette."). Optional. */
|
|
54
|
+
subtitle?: string;
|
|
55
|
+
/** Right-side chip in the title bar (e.g. "Step 1 of 6"). Optional. */
|
|
56
|
+
badge?: string;
|
|
57
|
+
/** Footer hint line (e.g. "↑/↓ navigate · ENTER confirm · q to cancel"). Defaults are sensible. */
|
|
58
|
+
footer?: string;
|
|
59
|
+
/** Picker rows. No upper limit; height clamps automatically. */
|
|
60
|
+
rows: PickerRow[];
|
|
61
|
+
/** Initial cursor index. Clamped to [0, rows.length - 1]. */
|
|
62
|
+
initialCursor?: number;
|
|
63
|
+
/** When true, an "Other" row is appended that drops to free-text entry. */
|
|
64
|
+
allowOther?: boolean;
|
|
65
|
+
/** Label for the appended Other row (default: "Other"). */
|
|
66
|
+
otherLabel?: string;
|
|
67
|
+
/** Description for the Other row. */
|
|
68
|
+
otherDescription?: string;
|
|
69
|
+
/** Pre-fill the Other free-text buffer. Used by env-var-derived defaults. */
|
|
70
|
+
prefilledOther?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Live-preview hook. Fires after a real cursor move only. Returns an
|
|
73
|
+
* array of preview lines to render INSIDE the picker frame (above the
|
|
74
|
+
* footer). Returning `undefined` or `[]` means "no preview".
|
|
75
|
+
*
|
|
76
|
+
* The picker takes care of the redraw — the callback must NOT write
|
|
77
|
+
* to stdout. Mirrors `openSrc/codex/codex-rs/tui/src/theme_picker.rs`
|
|
78
|
+
* (preview returns a row spec, never `stdout.write`).
|
|
79
|
+
*/
|
|
80
|
+
onCursorChange?: (cursorId: string, cursorIndex: number) => string[] | undefined;
|
|
81
|
+
/** Theme for chrome coloring; defaults to `dark`. */
|
|
82
|
+
theme?: Theme;
|
|
83
|
+
/**
|
|
84
|
+
* When true, the frame is erased on close so the next picker (or
|
|
85
|
+
* print) lands at the same screen position. Wizard sets this so
|
|
86
|
+
* each step REPLACES the previous frame instead of stacking
|
|
87
|
+
* downward on screen.
|
|
88
|
+
*/
|
|
89
|
+
eraseOnClose?: boolean;
|
|
90
|
+
}
|
|
91
|
+
export type PickFromListResult = {
|
|
92
|
+
kind: 'pick';
|
|
93
|
+
id: string;
|
|
94
|
+
} | {
|
|
95
|
+
kind: 'other';
|
|
96
|
+
text: string;
|
|
97
|
+
} | {
|
|
98
|
+
kind: 'cancelled';
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* Free-text-only entry. Used by the wizard's API-key step.
|
|
102
|
+
*
|
|
103
|
+
* Renders a single masked input row inside a framed panel. Same redraw
|
|
104
|
+
* contract as `pickFromList` — atomic frames, owns its region.
|
|
105
|
+
*/
|
|
106
|
+
export interface PromptTextOptions {
|
|
107
|
+
title: string;
|
|
108
|
+
subtitle?: string;
|
|
109
|
+
badge?: string;
|
|
110
|
+
/** Right-side chip in the title bar (e.g. "openai · cloud"). */
|
|
111
|
+
/** Pre-filled buffer (e.g. value from env). ENTER accepts as-is. */
|
|
112
|
+
prefilled?: string;
|
|
113
|
+
/** When true, render input as `·······abcd` (mask all but last 4). */
|
|
114
|
+
mask?: boolean;
|
|
115
|
+
/** Placeholder shown muted when the input is empty. */
|
|
116
|
+
placeholder?: string;
|
|
117
|
+
/** Footer hint. */
|
|
118
|
+
footer?: string;
|
|
119
|
+
/** Optional validator. Return undefined to accept; return string to show as an inline error. */
|
|
120
|
+
validate?: (raw: string) => string | undefined;
|
|
121
|
+
/** Theme for chrome coloring. */
|
|
122
|
+
theme?: Theme;
|
|
123
|
+
/** See PickFromListOptions.eraseOnClose. */
|
|
124
|
+
eraseOnClose?: boolean;
|
|
125
|
+
}
|
|
126
|
+
export type PromptTextResult = {
|
|
127
|
+
kind: 'accept';
|
|
128
|
+
text: string;
|
|
129
|
+
} | {
|
|
130
|
+
kind: 'cancelled';
|
|
131
|
+
};
|
|
132
|
+
export declare function isInternalPickerActive(): boolean;
|
|
133
|
+
interface FrameInputs {
|
|
134
|
+
theme: Theme;
|
|
135
|
+
title: string;
|
|
136
|
+
subtitle?: string;
|
|
137
|
+
badge?: string;
|
|
138
|
+
bodyLines: string[];
|
|
139
|
+
previewLines?: string[];
|
|
140
|
+
footer: string;
|
|
141
|
+
width: number;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Compute the full picker frame as a single string. Pure function so
|
|
145
|
+
* tests can assert on the exact output without driving a TTY.
|
|
146
|
+
*
|
|
147
|
+
* Layout (single column for now — wide-terminal two-column comes in a
|
|
148
|
+
* follow-up):
|
|
149
|
+
*
|
|
150
|
+
* ┌─ <title> ─────────────────────── <badge> ─┐
|
|
151
|
+
* │ <subtitle> │
|
|
152
|
+
* │ │
|
|
153
|
+
* │ <body line 1> │
|
|
154
|
+
* │ <body line 2> │
|
|
155
|
+
* │ ... │
|
|
156
|
+
* │ │ (preview block if present)
|
|
157
|
+
* │ <preview line 1> │
|
|
158
|
+
* │ <preview line 2> │
|
|
159
|
+
* │ │
|
|
160
|
+
* │ <footer> │
|
|
161
|
+
* └───────────────────────────────────────────┘
|
|
162
|
+
*/
|
|
163
|
+
export declare function renderFrame(f: FrameInputs): string;
|
|
164
|
+
/** ANSI-aware right-pad. Strips ANSI sequences when counting width. */
|
|
165
|
+
declare function padRightVisible(s: string, w: number): string;
|
|
166
|
+
declare function visibleLength(s: string): number;
|
|
167
|
+
declare function stripAnsi(s: string): string;
|
|
168
|
+
/** Simple word-wrap; doesn't try to be ANSI-aware (subtitle takes plain text). */
|
|
169
|
+
declare function wrap(s: string, w: number): string[];
|
|
170
|
+
declare function formatBodyRow(t: Theme, row: PickerRow, isSelected: boolean, valueColWidth: number, inner: number): string[];
|
|
171
|
+
export declare function pickFromList(opts: PickFromListOptions): Promise<PickFromListResult>;
|
|
172
|
+
declare function computeValueColumn(rows: PickerRow[]): number;
|
|
173
|
+
declare function defaultFooter(phase: 'pick' | 'other', allowOther: boolean): string;
|
|
174
|
+
export declare function promptText(opts: PromptTextOptions): Promise<PromptTextResult>;
|
|
175
|
+
export interface FramedInputOptions {
|
|
176
|
+
/**
|
|
177
|
+
* When true (default), the frame is **erased** when the picker
|
|
178
|
+
* closes — the cursor ends up where the frame started, so the next
|
|
179
|
+
* print overwrites the same screen region. Every caller in the
|
|
180
|
+
* wizard / `/config` / `/login` flows wants this behaviour:
|
|
181
|
+
* pickers are modal, not transcript-y.
|
|
182
|
+
*
|
|
183
|
+
* Set to false explicitly to leave the frame on screen as
|
|
184
|
+
* scrollback after close (the cursor lands one line below).
|
|
185
|
+
* No current callers use this; reserved for future surfaces
|
|
186
|
+
* (e.g. an `/agents` picker where the user wants the list to
|
|
187
|
+
* stay visible).
|
|
188
|
+
*/
|
|
189
|
+
eraseOnClose?: boolean;
|
|
190
|
+
}
|
|
191
|
+
/** Pure helpers exposed for unit tests. */
|
|
192
|
+
export declare const __test: {
|
|
193
|
+
renderFrame: typeof renderFrame;
|
|
194
|
+
formatBodyRow: typeof formatBodyRow;
|
|
195
|
+
visibleLength: typeof visibleLength;
|
|
196
|
+
stripAnsi: typeof stripAnsi;
|
|
197
|
+
wrap: typeof wrap;
|
|
198
|
+
padRightVisible: typeof padRightVisible;
|
|
199
|
+
computeValueColumn: typeof computeValueColumn;
|
|
200
|
+
defaultFooter: typeof defaultFooter;
|
|
201
|
+
};
|
|
202
|
+
export {};
|