@giovannijecha/jecode 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -16
- package/dist/command-settings.js +19 -0
- package/dist/commands.js +8 -12
- package/dist/credential-commands.js +63 -80
- package/dist/credentials.js +7 -1
- package/dist/model-command.js +171 -0
- package/dist/permission-command.js +52 -53
- package/dist/provider-commands.js +71 -228
- package/dist/provider-errors.js +4 -3
- package/dist/provider-label.js +2 -2
- package/dist/settings-command.js +43 -98
- package/dist/tui/app-workflows.js +10 -5
- package/dist/tui/app.js +1 -1
- package/dist/tui/components/composer.js +1 -1
- package/dist/tui/components/footer.js +1 -1
- package/dist/tui/components/menu.js +27 -15
- package/dist/tui/components/messages.js +2 -2
- package/dist/tui/components/prompt.js +2 -2
- package/dist/tui/components/status.js +2 -2
- package/dist/tui/components/tool.js +3 -3
- package/dist/tui/editor.js +54 -7
- package/dist/tui/feedback.js +2 -2
- package/dist/tui/field.js +1 -1
- package/dist/tui/help.js +4 -1
- package/dist/tui/input.js +4 -0
- package/dist/tui/keys.js +14 -3
- package/dist/tui/overlay.js +6 -0
- package/dist/tui/picker.js +26 -10
- package/dist/tui/session-view.js +2 -2
- package/dist/tui/view.js +1 -1
- package/dist/ui/inline.js +2 -2
- package/dist/ui/markdown.js +7 -7
- package/dist/ui/theme.js +4 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
<a href="https://github.com/giovannijecha/jecode/releases">Releases</a>
|
|
26
26
|
</p>
|
|
27
27
|
|
|
28
|
-
> Jecode is an early 0.
|
|
28
|
+
> Jecode is an early 0.4.x release. The core loop is usable today; commands and
|
|
29
29
|
> terminal interactions may still evolve before 1.0.
|
|
30
30
|
|
|
31
31
|
## Why Jecode
|
|
@@ -146,10 +146,11 @@ published package runs no compilation or installation scripts.
|
|
|
146
146
|
## First session
|
|
147
147
|
|
|
148
148
|
Jecode opens on an empty composer instead of forcing a setup wizard. Type
|
|
149
|
-
**/
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
149
|
+
**/providers** to connect the services you use, then **/models** to choose from
|
|
150
|
+
their combined live catalogues. **/settings** keeps the selected model and the
|
|
151
|
+
remaining non-secret defaults together. An API key can remain in memory for
|
|
152
|
+
the current session or be saved explicitly under **~/.jecode**; it is never
|
|
153
|
+
stored in the workspace.
|
|
153
154
|
|
|
154
155
|
| Provider ID | Authentication | Notes |
|
|
155
156
|
|---|---|---|
|
|
@@ -158,7 +159,8 @@ workspace.
|
|
|
158
159
|
| openai-codex | ChatGPT OAuth | Experimental; uses eligible ChatGPT Codex access |
|
|
159
160
|
| ollama | OLLAMA_API_KEY for Cloud/remote | Cloud with a key, local without one |
|
|
160
161
|
|
|
161
|
-
Choose **
|
|
162
|
+
Choose **ChatGPT** in **/providers** to sign in on OpenAI's website without
|
|
163
|
+
pasting a key.
|
|
162
164
|
Jecode offers a local browser callback and a device-code flow; WSL and remote
|
|
163
165
|
terminals default to the device code. The connection is saved only after the
|
|
164
166
|
flow completes. Availability and usage limits are determined by the ChatGPT
|
|
@@ -168,10 +170,10 @@ and is not an endorsement of Jecode by OpenAI.
|
|
|
168
170
|
Anthropic remains API-key only. Jecode does not reuse a Claude consumer
|
|
169
171
|
subscription or copy credentials from another client.
|
|
170
172
|
|
|
171
|
-
Choose **
|
|
172
|
-
|
|
173
|
-
**https://ollama.com**; without a key, Jecode uses the local
|
|
174
|
-
**http://127.0.0.1:11434**. Remote custom endpoints must use HTTPS.
|
|
173
|
+
Choose **Ollama** in **/providers** to manage its API key and select **cloud**,
|
|
174
|
+
**local**, or a custom endpoint. Existing users with an Ollama API key
|
|
175
|
+
automatically use **https://ollama.com**; without a key, Jecode uses the local
|
|
176
|
+
daemon at **http://127.0.0.1:11434**. Remote custom endpoints must use HTTPS.
|
|
175
177
|
|
|
176
178
|
## Use the TUI
|
|
177
179
|
|
|
@@ -179,12 +181,11 @@ Type **/** to open searchable command completion inside the composer.
|
|
|
179
181
|
|
|
180
182
|
| Command | What it does |
|
|
181
183
|
|---|---|
|
|
182
|
-
| /settings | Manage
|
|
184
|
+
| /settings | Manage the selected model, limits, effort, motion, and provider access |
|
|
183
185
|
| /effort | Change and save reasoning effort directly |
|
|
184
|
-
| /providers |
|
|
185
|
-
| /models | Search
|
|
186
|
-
| /
|
|
187
|
-
| /permissions | Manage session tool access and remembered approvals |
|
|
186
|
+
| /providers | Manage API keys, ChatGPT sign-in, and Ollama connections |
|
|
187
|
+
| /models | Search models across every available provider and select one |
|
|
188
|
+
| /permissions | Change session tool access inline and review remembered approvals |
|
|
188
189
|
| /new | Start a clean conversation and reset session tool permissions |
|
|
189
190
|
| /export | Save a timestamped Markdown transcript in the launch directory |
|
|
190
191
|
| /help | Open a temporary keyboard reference in the composer dock |
|
|
@@ -193,6 +194,10 @@ Type **/** to open searchable command completion inside the composer.
|
|
|
193
194
|
Useful controls:
|
|
194
195
|
|
|
195
196
|
- **Up/Down** moves through command suggestions, menus, and input history.
|
|
197
|
+
- **Left/Right** moves the composer cursor or changes an inline menu value;
|
|
198
|
+
**Ctrl+Left/Right** moves by word.
|
|
199
|
+
- **Backspace/Delete** removes one character; **Ctrl+Backspace/Delete** removes
|
|
200
|
+
one word. **Home/End** moves to the start or end of the composer.
|
|
196
201
|
- **Tab** completes a slash command without running it; **Enter** sends.
|
|
197
202
|
- **Alt+Enter** inserts a newline.
|
|
198
203
|
- **Esc** closes a menu or interrupts the foreground operation.
|
|
@@ -229,7 +234,9 @@ Persistent preferences live in **~/.jecode/settings.json**. Explicitly saved
|
|
|
229
234
|
API keys live in **~/.jecode/credentials.json**; the ChatGPT OAuth account lives
|
|
230
235
|
separately in **~/.jecode/accounts.json**. Both secret stores use owner-only
|
|
231
236
|
permissions where the operating system supports them. Environment API keys
|
|
232
|
-
always win.
|
|
237
|
+
always win. Model selection saves the provider and model as one change; the
|
|
238
|
+
separate startup flags remain available for automation and override that saved
|
|
239
|
+
choice.
|
|
233
240
|
|
|
234
241
|
Jecode has one interface theme: dark Steel. **NO_COLOR** is supported for
|
|
235
242
|
terminals and pipelines that disable colour.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Shared persistence boundary for commands that change non-secret defaults.
|
|
2
|
+
import { updateSettings } from "./settings.js";
|
|
3
|
+
export async function saveCommandSettings(host, patch) {
|
|
4
|
+
try {
|
|
5
|
+
if (host.saveSettings === undefined)
|
|
6
|
+
await updateSettings(patch);
|
|
7
|
+
else
|
|
8
|
+
await host.saveSettings(patch);
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
host.emit({
|
|
13
|
+
kind: "notice",
|
|
14
|
+
text: `could not save settings · ${error.message}`,
|
|
15
|
+
tone: "error",
|
|
16
|
+
});
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
package/dist/commands.js
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
//
|
|
3
3
|
// One of them does reach the network — a menu of models cannot be built
|
|
4
4
|
// without asking the provider what it has — but none of them ever sends a
|
|
5
|
-
// message. Provider and
|
|
6
|
-
// this file keeps
|
|
7
|
-
import { modelsCommand
|
|
8
|
-
import {
|
|
5
|
+
// message. Provider access and model selection stay in their own commands;
|
|
6
|
+
// this file keeps discovery, dispatch, and local session operations.
|
|
7
|
+
import { modelsCommand } from "./model-command.js";
|
|
8
|
+
import { providersCommand } from "./provider-commands.js";
|
|
9
9
|
import { permissionsCommand } from "./permission-command.js";
|
|
10
10
|
import { effortCommand, settingsCommand } from "./settings-command.js";
|
|
11
11
|
import { emptyUsage } from "./usage.js";
|
|
@@ -13,8 +13,8 @@ import { emptyUsage } from "./usage.js";
|
|
|
13
13
|
* The commands, declared once.
|
|
14
14
|
*
|
|
15
15
|
* Deliberately short. `/settings` owns persistent defaults; the narrower
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* model, provider-access, and effort commands remain useful direct paths into
|
|
17
|
+
* the same interactions.
|
|
18
18
|
*/
|
|
19
19
|
export const COMMANDS = [
|
|
20
20
|
{ name: "help", blurb: "show keyboard controls" },
|
|
@@ -24,9 +24,8 @@ export const COMMANDS = [
|
|
|
24
24
|
{ name: "permissions", blurb: "manage session tool access" },
|
|
25
25
|
{ name: "settings", blurb: "change and save jecode defaults" },
|
|
26
26
|
{ name: "effort", blurb: "set the reasoning effort" },
|
|
27
|
-
{ name: "
|
|
28
|
-
{ name: "
|
|
29
|
-
{ name: "providers", blurb: "pick a provider" },
|
|
27
|
+
{ name: "models", blurb: "choose from every available provider" },
|
|
28
|
+
{ name: "providers", blurb: "manage provider access and connections" },
|
|
30
29
|
];
|
|
31
30
|
export async function handleCommand(line, session, host) {
|
|
32
31
|
const [name] = line.slice(1).trim().split(/\s+/);
|
|
@@ -69,9 +68,6 @@ export async function handleCommand(line, session, host) {
|
|
|
69
68
|
case "effort":
|
|
70
69
|
await effortCommand(session, host);
|
|
71
70
|
return "handled";
|
|
72
|
-
case "credentials":
|
|
73
|
-
await credentialsCommand(session, host);
|
|
74
|
-
return "handled";
|
|
75
71
|
case "models":
|
|
76
72
|
await modelsCommand(session, host);
|
|
77
73
|
return "handled";
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
//
|
|
2
|
-
import {
|
|
1
|
+
// Masked API-key interaction shared by provider connection flows.
|
|
2
|
+
import { credentialSource, forgetSaved, forgetSession, hasSaved, hold, keep, storeLabel, } from "./credentials.js";
|
|
3
3
|
import { EMPTY } from "./tui/editor.js";
|
|
4
|
-
import {
|
|
5
|
-
import { openAIAccountHint } from "./openai-account.js";
|
|
6
|
-
import { ensureOpenAIAccount, openAIAccountCommand, } from "./openai-account-command.js";
|
|
7
|
-
import { credentialSource, forgetSaved, hasSaved, hold, keep, storeLabel, } from "./credentials.js";
|
|
4
|
+
import { heading } from "./tui/picker.js";
|
|
8
5
|
/** Ask for a key, then ask separately whether it may be written to disk. */
|
|
9
6
|
export async function askForKey(name, host, pal) {
|
|
10
7
|
if (host.type === undefined || host.choose === undefined)
|
|
@@ -43,109 +40,95 @@ export async function askForKey(name, host, pal) {
|
|
|
43
40
|
return true;
|
|
44
41
|
}
|
|
45
42
|
catch (error) {
|
|
46
|
-
host.emit({
|
|
43
|
+
host.emit({
|
|
44
|
+
kind: "notice",
|
|
45
|
+
text: `could not save API key · ${error.message}`,
|
|
46
|
+
tone: "error",
|
|
47
|
+
});
|
|
47
48
|
return false;
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
return false;
|
|
51
52
|
}
|
|
52
|
-
|
|
53
|
+
/** Manage one provider key without ever placing its value on screen. */
|
|
54
|
+
export async function apiKeyCommand(name, label, session, host) {
|
|
53
55
|
const choose = chooser(host);
|
|
54
56
|
if (choose === undefined)
|
|
55
57
|
return;
|
|
56
|
-
const index = await choose({
|
|
57
|
-
title: heading("authentication", "secrets are never shown", session.palette),
|
|
58
|
-
options: PROVIDERS.map((provider) => ({
|
|
59
|
-
label: provider.auth.kind === "api-key" ? provider.auth.keyVar : `${provider.auth.label} account`,
|
|
60
|
-
hint: provider.auth.kind === "api-key"
|
|
61
|
-
? credentialSource(provider.auth.keyVar) ?? "missing"
|
|
62
|
-
: openAIAccountHint(),
|
|
63
|
-
})),
|
|
64
|
-
index: Math.max(0, PROVIDERS.findIndex((provider) => provider.id === session.provider.id)),
|
|
65
|
-
});
|
|
66
|
-
if (index === undefined)
|
|
67
|
-
return;
|
|
68
|
-
const provider = PROVIDERS[index];
|
|
69
|
-
if (provider === undefined)
|
|
70
|
-
return;
|
|
71
|
-
if (provider.auth.kind === "oauth") {
|
|
72
|
-
await openAIAccountCommand(session, host);
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
const name = provider.auth.keyVar;
|
|
76
58
|
const source = credentialSource(name);
|
|
77
59
|
if (source === "environment") {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
60
|
+
if (!hasSaved(name)) {
|
|
61
|
+
host.emit({
|
|
62
|
+
kind: "notice",
|
|
63
|
+
text: `${label} API key comes from the environment · restart after changing it`,
|
|
64
|
+
tone: "info",
|
|
65
|
+
});
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const action = await choose({
|
|
69
|
+
title: heading(`${label} API key`, `${name} · environment`, session.palette),
|
|
70
|
+
description: "The environment value is read-only. A saved copy is currently shadowed.",
|
|
71
|
+
options: [{ label: "forget saved copy", hint: storeLabel(), key: "f" }],
|
|
72
|
+
index: 0,
|
|
82
73
|
});
|
|
83
|
-
if (
|
|
84
|
-
await
|
|
74
|
+
if (action === 0)
|
|
75
|
+
await forgetSavedKey(name, host);
|
|
85
76
|
return;
|
|
86
77
|
}
|
|
87
78
|
const actions = [
|
|
88
|
-
{
|
|
89
|
-
|
|
79
|
+
{
|
|
80
|
+
label: source === undefined ? "add API key" : "replace API key",
|
|
81
|
+
hint: source === undefined ? "session or owner-only file" : `currently ${source}`,
|
|
82
|
+
key: "r",
|
|
83
|
+
},
|
|
84
|
+
...(source === "session"
|
|
85
|
+
? [{ label: "clear session key", hint: "this process only", key: "c" }]
|
|
86
|
+
: []),
|
|
87
|
+
...(hasSaved(name)
|
|
88
|
+
? [{ label: "forget saved copy", hint: storeLabel(), key: "f" }]
|
|
89
|
+
: []),
|
|
90
90
|
];
|
|
91
|
-
const
|
|
92
|
-
title: heading(name
|
|
91
|
+
const index = await choose({
|
|
92
|
+
title: heading(`${label} API key`, `${name} · ${source ?? "missing"}`, session.palette),
|
|
93
93
|
options: actions,
|
|
94
94
|
index: 0,
|
|
95
95
|
});
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
96
|
+
const action = index === undefined ? undefined : actions[index]?.key;
|
|
97
|
+
if (action === "r")
|
|
98
|
+
await askForKey(name, host, session.palette);
|
|
99
|
+
else if (action === "c")
|
|
100
|
+
clearSessionKey(name, host);
|
|
101
|
+
else if (action === "f")
|
|
102
|
+
await forgetSavedKey(name, host);
|
|
103
103
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
host.emit({ kind: "notice", text: blocked, tone: "error" });
|
|
116
|
-
return false;
|
|
117
|
-
}
|
|
118
|
-
await askForKey(provider.auth.keyVar, host, session.palette);
|
|
119
|
-
return provider.blocked() === undefined;
|
|
120
|
-
}
|
|
121
|
-
export function authenticationNeed(provider) {
|
|
122
|
-
return provider.auth.kind === "oauth" ? `${provider.auth.label} sign-in` : "an API key";
|
|
123
|
-
}
|
|
124
|
-
async function offerForget(name, session, host, hint) {
|
|
125
|
-
if (host.choose === undefined)
|
|
126
|
-
return;
|
|
127
|
-
const index = await host.choose({
|
|
128
|
-
title: heading(name, hint, session.palette),
|
|
129
|
-
options: [
|
|
130
|
-
{ label: "keep saved copy", key: "k" },
|
|
131
|
-
{ label: "forget saved copy", hint: storeLabel(), key: "f" },
|
|
132
|
-
],
|
|
133
|
-
index: 0,
|
|
104
|
+
function clearSessionKey(name, host) {
|
|
105
|
+
const removed = forgetSession(name);
|
|
106
|
+
const fallback = credentialSource(name);
|
|
107
|
+
host.emit({
|
|
108
|
+
kind: "notice",
|
|
109
|
+
text: removed
|
|
110
|
+
? fallback === undefined
|
|
111
|
+
? "session API key removed"
|
|
112
|
+
: `session API key removed · ${fallback} copy now active`
|
|
113
|
+
: "no session API key",
|
|
114
|
+
tone: removed ? "info" : "warn",
|
|
134
115
|
});
|
|
135
|
-
if (index === 1)
|
|
136
|
-
await forget(name, host);
|
|
137
116
|
}
|
|
138
|
-
async function
|
|
117
|
+
async function forgetSavedKey(name, host) {
|
|
139
118
|
try {
|
|
140
119
|
const removed = await forgetSaved(name);
|
|
141
120
|
host.emit({
|
|
142
121
|
kind: "notice",
|
|
143
|
-
text: removed ? "API key removed" : "no saved API key",
|
|
122
|
+
text: removed ? "saved API key removed" : "no saved API key",
|
|
144
123
|
tone: removed ? "info" : "warn",
|
|
145
124
|
});
|
|
146
125
|
}
|
|
147
126
|
catch (error) {
|
|
148
|
-
host.emit({
|
|
127
|
+
host.emit({
|
|
128
|
+
kind: "notice",
|
|
129
|
+
text: `could not remove API key · ${error.message}`,
|
|
130
|
+
tone: "error",
|
|
131
|
+
});
|
|
149
132
|
}
|
|
150
133
|
}
|
|
151
134
|
function chooser(host) {
|
package/dist/credentials.js
CHANGED
|
@@ -60,6 +60,10 @@ export function hasSaved(name) {
|
|
|
60
60
|
export function hold(name, value) {
|
|
61
61
|
held.set(name, value);
|
|
62
62
|
}
|
|
63
|
+
/** Remove only the value held by this process. Saved and environment values remain. */
|
|
64
|
+
export function forgetSession(name) {
|
|
65
|
+
return held.delete(name);
|
|
66
|
+
}
|
|
63
67
|
/**
|
|
64
68
|
* Take a key and write it down, returning the path it went to.
|
|
65
69
|
*
|
|
@@ -74,8 +78,10 @@ export async function keep(name, value) {
|
|
|
74
78
|
return withStoreLock(file, async () => {
|
|
75
79
|
const all = { ...readSavedStore(), [name]: value };
|
|
76
80
|
await persist(file, all);
|
|
77
|
-
hold(name, value);
|
|
78
81
|
saved = all;
|
|
82
|
+
// A newly saved replacement must become active immediately. Otherwise an
|
|
83
|
+
// older session-only value would keep shadowing the value just written.
|
|
84
|
+
held.delete(name);
|
|
79
85
|
return file;
|
|
80
86
|
});
|
|
81
87
|
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Model-first runtime selection across every provider that can answer now.
|
|
2
|
+
import { saveCommandSettings } from "./command-settings.js";
|
|
3
|
+
import { compatibleEffort } from "./effort.js";
|
|
4
|
+
import { providerFailure } from "./provider-errors.js";
|
|
5
|
+
import { providerLabel } from "./provider-label.js";
|
|
6
|
+
import { PROVIDERS } from "./providers/index.js";
|
|
7
|
+
import { readSettings } from "./settings.js";
|
|
8
|
+
/** Build one searchable catalogue from every provider that is usable now. */
|
|
9
|
+
export async function modelsCommand(session, host, behavior = {}, providers = PROVIDERS) {
|
|
10
|
+
const choose = chooser(host);
|
|
11
|
+
if (choose === undefined)
|
|
12
|
+
return false;
|
|
13
|
+
const availability = providers.map((provider) => ({
|
|
14
|
+
provider,
|
|
15
|
+
blocked: provider.blocked(),
|
|
16
|
+
}));
|
|
17
|
+
const connected = availability
|
|
18
|
+
.filter((entry) => entry.blocked === undefined)
|
|
19
|
+
.map((entry) => entry.provider);
|
|
20
|
+
const disconnected = availability
|
|
21
|
+
.filter((entry) => entry.blocked !== undefined)
|
|
22
|
+
.map((entry) => entry.provider);
|
|
23
|
+
if (connected.length === 0) {
|
|
24
|
+
host.emit({
|
|
25
|
+
kind: "notice",
|
|
26
|
+
text: "no providers are connected · use /providers",
|
|
27
|
+
tone: "warn",
|
|
28
|
+
});
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
throwIfAborted(host.signal);
|
|
32
|
+
host.status?.("Loading model catalogs");
|
|
33
|
+
let settled;
|
|
34
|
+
try {
|
|
35
|
+
settled = await Promise.allSettled(connected.map(async (provider) => ({
|
|
36
|
+
provider,
|
|
37
|
+
models: await provider.models(host.signal, (status) => host.status?.(`${providerLabel(provider.id)} · ${status}`)),
|
|
38
|
+
})));
|
|
39
|
+
// allSettled deliberately preserves partial provider failures, but an
|
|
40
|
+
// explicit command cancellation is not a catalogue failure.
|
|
41
|
+
throwIfAborted(host.signal);
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
host.status?.(undefined);
|
|
45
|
+
}
|
|
46
|
+
const choices = [];
|
|
47
|
+
const failed = [];
|
|
48
|
+
for (let index = 0; index < settled.length; index++) {
|
|
49
|
+
const result = settled[index];
|
|
50
|
+
const provider = connected[index];
|
|
51
|
+
if (result === undefined || provider === undefined)
|
|
52
|
+
continue;
|
|
53
|
+
if (result.status === "rejected") {
|
|
54
|
+
failed.push({
|
|
55
|
+
provider,
|
|
56
|
+
error: result.reason instanceof Error ? result.reason : new Error(String(result.reason)),
|
|
57
|
+
});
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
for (const model of result.value.models)
|
|
61
|
+
choices.push({ provider, model });
|
|
62
|
+
}
|
|
63
|
+
if (choices.length === 0) {
|
|
64
|
+
host.emit({
|
|
65
|
+
kind: "notice",
|
|
66
|
+
text: emptyCatalogMessage(connected, failed),
|
|
67
|
+
tone: failed.length === connected.length ? "error" : "warn",
|
|
68
|
+
});
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
const description = catalogDescription(disconnected, failed);
|
|
72
|
+
const index = await choose({
|
|
73
|
+
title: [],
|
|
74
|
+
...(description === undefined ? {} : { description }),
|
|
75
|
+
options: choices.map((choice) => ({
|
|
76
|
+
label: choice.model,
|
|
77
|
+
// Provider identity is part of the choice, not optional help: keep it
|
|
78
|
+
// visible even when the terminal is only at the supported minimum.
|
|
79
|
+
value: providerLabel(choice.provider.id),
|
|
80
|
+
})),
|
|
81
|
+
searchable: true,
|
|
82
|
+
query: "",
|
|
83
|
+
index: Math.max(0, choices.findIndex((choice) => choice.provider.id === session.provider.id && choice.model === session.model)),
|
|
84
|
+
});
|
|
85
|
+
throwIfAborted(host.signal);
|
|
86
|
+
const chosen = index === undefined ? undefined : choices[index];
|
|
87
|
+
if (chosen === undefined)
|
|
88
|
+
return false;
|
|
89
|
+
const beforeEffort = session.config.effort;
|
|
90
|
+
const alignment = await alignEffort(chosen.provider, chosen.model, beforeEffort, host);
|
|
91
|
+
if (!alignment.ok)
|
|
92
|
+
return false;
|
|
93
|
+
if (behavior.save !== false) {
|
|
94
|
+
const saved = readSettings();
|
|
95
|
+
const patch = {
|
|
96
|
+
provider: chosen.provider.id,
|
|
97
|
+
models: { ...saved.models, [chosen.provider.id]: chosen.model },
|
|
98
|
+
...(alignment.effort === beforeEffort ? {} : { effort: alignment.effort }),
|
|
99
|
+
};
|
|
100
|
+
if (!(await saveCommandSettings(host, patch)))
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
// Commit the runtime selection only after both provider validation and
|
|
104
|
+
// persistence succeed, so neither the footer nor a later turn can observe a
|
|
105
|
+
// half-applied provider/model pair.
|
|
106
|
+
session.provider = chosen.provider;
|
|
107
|
+
session.model = chosen.model;
|
|
108
|
+
session.config.providerId = chosen.provider.id;
|
|
109
|
+
session.config.model = chosen.model;
|
|
110
|
+
session.config.effort = alignment.effort;
|
|
111
|
+
if (behavior.announce !== false) {
|
|
112
|
+
host.emit({
|
|
113
|
+
kind: "notice",
|
|
114
|
+
text: `model · ${providerLabel(chosen.provider.id)} · ${chosen.model}`,
|
|
115
|
+
tone: "info",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
async function alignEffort(provider, model, effort, host) {
|
|
121
|
+
if (provider.efforts === undefined)
|
|
122
|
+
return { ok: true, effort };
|
|
123
|
+
try {
|
|
124
|
+
throwIfAborted(host.signal);
|
|
125
|
+
const supported = await provider.efforts(model, host.signal, (status) => host.status?.(status));
|
|
126
|
+
throwIfAborted(host.signal);
|
|
127
|
+
return { ok: true, effort: compatibleEffort(effort, supported) ?? effort };
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
throwIfAborted(host.signal);
|
|
131
|
+
host.emit({
|
|
132
|
+
kind: "notice",
|
|
133
|
+
text: providerFailure(provider, error, true),
|
|
134
|
+
tone: "error",
|
|
135
|
+
});
|
|
136
|
+
return { ok: false };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function catalogDescription(disconnected, failed) {
|
|
140
|
+
const parts = [
|
|
141
|
+
disconnected.length === 0
|
|
142
|
+
? undefined
|
|
143
|
+
: `Not connected: ${disconnected.map((provider) => providerLabel(provider.id)).join(", ")}`,
|
|
144
|
+
failed.length === 0
|
|
145
|
+
? undefined
|
|
146
|
+
: `Unavailable: ${failed.map((entry) => providerLabel(entry.provider.id)).join(", ")}`,
|
|
147
|
+
].filter((part) => part !== undefined);
|
|
148
|
+
return parts.length === 0 ? undefined : `${parts.join(" · ")} · manage in /providers`;
|
|
149
|
+
}
|
|
150
|
+
function emptyCatalogMessage(connected, failed) {
|
|
151
|
+
if (failed.length === 1 && connected.length === 1) {
|
|
152
|
+
const one = failed[0];
|
|
153
|
+
if (one !== undefined)
|
|
154
|
+
return providerFailure(one.provider, one.error, true);
|
|
155
|
+
}
|
|
156
|
+
if (failed.length > 0) {
|
|
157
|
+
return `model catalogs unavailable: ${failed.map((entry) => providerLabel(entry.provider.id)).join(", ")} · /providers`;
|
|
158
|
+
}
|
|
159
|
+
return "connected providers offer no models · check /providers";
|
|
160
|
+
}
|
|
161
|
+
function chooser(host) {
|
|
162
|
+
if (host.choose === undefined) {
|
|
163
|
+
host.emit({ kind: "notice", text: "that command needs the screen", tone: "warn" });
|
|
164
|
+
}
|
|
165
|
+
return host.choose;
|
|
166
|
+
}
|
|
167
|
+
function throwIfAborted(signal) {
|
|
168
|
+
if (signal?.aborted !== true)
|
|
169
|
+
return;
|
|
170
|
+
throw signal.reason instanceof Error ? signal.reason : new Error("interrupted");
|
|
171
|
+
}
|