dsh-ssh-tui 0.3.5 → 0.3.6
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.en.md +19 -14
- package/README.md +17 -13
- package/lib/index.js +2 -0
- package/lib/index.js.map +1 -1
- package/lib/route-memory.js +55 -0
- package/lib/route-memory.js.map +1 -0
- package/lib/tui.js +676 -254
- package/lib/tui.js.map +1 -1
- package/lib/types/route-memory.d.ts +35 -0
- package/lib/types/tui.d.ts +103 -8
- package/package.json +1 -1
package/lib/tui.js
CHANGED
|
@@ -24,7 +24,9 @@ import { settingsNamespace } from '@deepseek-ai/dsh-settings';
|
|
|
24
24
|
import { formatSessionTime, listResumableSessions } from './session-list.js';
|
|
25
25
|
import { defaultReasoningEffort } from './reasoning.js';
|
|
26
26
|
import { checkForPluginUpdate } from './update-check.js';
|
|
27
|
+
import { ROUTE_MEMORY_NAMESPACE, parseRouteMemory, rememberedRouteFor, upsertRememberedRoute, } from './route-memory.js';
|
|
27
28
|
import { DEFAULT_SUBAGENT_MODEL, SUBAGENT_SETTINGS_NAMESPACE, defaultSubagentModelForProvider, subagentModelMatchesProvider, subagentSettingsValue, } from './subagent-model.js';
|
|
29
|
+
const ROUTE_MEMORY_NS = ROUTE_MEMORY_NAMESPACE;
|
|
28
30
|
import { UserQuestionError, } from '@deepseek-ai/dsh-user-questions';
|
|
29
31
|
const PROVIDER_TEMPLATES = {
|
|
30
32
|
official: {
|
|
@@ -68,6 +70,26 @@ const WAIT_INDICATOR_MS = 8000;
|
|
|
68
70
|
const MIN_PAINT_INTERVAL_MS = 40;
|
|
69
71
|
const MAX_PAINT_INTERVAL_MS = 1000;
|
|
70
72
|
const DSR_PROBE_TIMEOUT_MS = 800;
|
|
73
|
+
/** Compact token count, matching the web stats line (517 / 12.2K / 1.2M). */
|
|
74
|
+
export function formatTokens(n) {
|
|
75
|
+
const scaled = (value) => value >= 100 ? String(Math.round(value)) : String(Math.round(value * 10) / 10);
|
|
76
|
+
if (n < 1_000)
|
|
77
|
+
return String(n);
|
|
78
|
+
if (n < 1_000_000)
|
|
79
|
+
return `${scaled(n / 1_000)}K`;
|
|
80
|
+
return `${scaled(n / 1_000_000)}M`;
|
|
81
|
+
}
|
|
82
|
+
/** Compact duration, matching the web stats line (45.2s / 2m42s). */
|
|
83
|
+
export function formatDuration(ms) {
|
|
84
|
+
const seconds = ms / 1_000;
|
|
85
|
+
if (seconds < 60)
|
|
86
|
+
return `${Math.round(seconds * 10) / 10}s`;
|
|
87
|
+
const whole = Math.round(seconds);
|
|
88
|
+
return `${Math.floor(whole / 60)}m${whole % 60}s`;
|
|
89
|
+
}
|
|
90
|
+
export function formatTokensPerSecond(tokensPerSecond) {
|
|
91
|
+
return `${Math.round(tokensPerSecond)} tok/s`;
|
|
92
|
+
}
|
|
71
93
|
/**
|
|
72
94
|
* Explicit env/config always wins. Otherwise local TTYs stay snappy and SSH
|
|
73
95
|
* sessions pick a tier from a measured round-trip (CSI 6n), falling back to
|
|
@@ -103,9 +125,179 @@ export function paintLinkLabel(kind, intervalMs, probed) {
|
|
|
103
125
|
return `本机绘制 ${intervalMs}ms`;
|
|
104
126
|
return probed ? `SSH 绘制 ${intervalMs}ms` : `SSH 绘制 ${intervalMs}ms(未测到往返)`;
|
|
105
127
|
}
|
|
128
|
+
/** Signal-bar quality from a measured SSH round-trip, or local TTY. */
|
|
129
|
+
export function linkQualityOf(kind, rttMs) {
|
|
130
|
+
if (kind === 'local')
|
|
131
|
+
return 'local';
|
|
132
|
+
if (rttMs === undefined || !Number.isFinite(rttMs) || rttMs < 0)
|
|
133
|
+
return 'unknown';
|
|
134
|
+
if (rttMs < 50)
|
|
135
|
+
return 'good';
|
|
136
|
+
if (rttMs < 150)
|
|
137
|
+
return 'ok';
|
|
138
|
+
if (rttMs < 350)
|
|
139
|
+
return 'slow';
|
|
140
|
+
return 'poor';
|
|
141
|
+
}
|
|
142
|
+
/** How many filled signal pips: 4 local/fast, 3 ok, 2 slow, 1 poor, 0 unknown. */
|
|
143
|
+
export function linkSignalPips(quality) {
|
|
144
|
+
if (quality === 'local' || quality === 'good')
|
|
145
|
+
return 4;
|
|
146
|
+
if (quality === 'ok')
|
|
147
|
+
return 3;
|
|
148
|
+
if (quality === 'slow')
|
|
149
|
+
return 2;
|
|
150
|
+
if (quality === 'poor')
|
|
151
|
+
return 1;
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
const LINK_PIP_COLOR = {
|
|
155
|
+
0: '90',
|
|
156
|
+
1: '31',
|
|
157
|
+
2: '33',
|
|
158
|
+
3: '32',
|
|
159
|
+
4: '32',
|
|
160
|
+
};
|
|
161
|
+
/** Compact footer chip: `SSH ●●●○ 90ms` — 1 pip red, 2 yellow, 3+ green. */
|
|
162
|
+
export function formatLinkQualityChip(kind, intervalMs, rttMs, probed, color = false) {
|
|
163
|
+
const quality = linkQualityOf(kind, probed ? rttMs : undefined);
|
|
164
|
+
const filled = linkSignalPips(quality);
|
|
165
|
+
const pips = `${'●'.repeat(filled)}${'○'.repeat(4 - filled)}`;
|
|
166
|
+
const colored = color
|
|
167
|
+
? `\x1b[${LINK_PIP_COLOR[filled] ?? '90'}m${pips}\x1b[0m`
|
|
168
|
+
: pips;
|
|
169
|
+
if (kind === 'local')
|
|
170
|
+
return `本机 ${colored}`;
|
|
171
|
+
const delay = probed && rttMs !== undefined && Number.isFinite(rttMs)
|
|
172
|
+
? `${Math.round(rttMs)}ms`
|
|
173
|
+
: `${intervalMs}ms`;
|
|
174
|
+
return `SSH ${colored} ${delay}`;
|
|
175
|
+
}
|
|
176
|
+
export function providerShortCode(provider) {
|
|
177
|
+
const id = provider.trim();
|
|
178
|
+
if (id === 'deepseek-official' || id === 'deepseek')
|
|
179
|
+
return 'DeepSeek 官方';
|
|
180
|
+
if (id === 'xai' || id === 'grok' || id.startsWith('xai-'))
|
|
181
|
+
return 'SuperGrok';
|
|
182
|
+
if (id === 'opencode-go')
|
|
183
|
+
return 'OpenCode Go';
|
|
184
|
+
if (id === 'opencode')
|
|
185
|
+
return 'OpenCode Zen';
|
|
186
|
+
return id;
|
|
187
|
+
}
|
|
188
|
+
/** Stats groups in drop order (last is dropped first when the row is too wide). */
|
|
189
|
+
export function footerStatsGroups(stats) {
|
|
190
|
+
const groups = [];
|
|
191
|
+
if (stats.steps > 0)
|
|
192
|
+
groups.push(`${stats.turns} 轮 · ${stats.steps} 步`);
|
|
193
|
+
const billedInput = stats.inputTokens + stats.cacheReadTokens + stats.cacheWriteTokens;
|
|
194
|
+
if (billedInput > 0 || stats.outputTokens > 0) {
|
|
195
|
+
groups.push(`输入 ${formatTokens(billedInput)} · 输出 ${formatTokens(stats.outputTokens)}`);
|
|
196
|
+
}
|
|
197
|
+
const speeds = [];
|
|
198
|
+
if (stats.decodeMs > 0 && stats.decodeTokens > 0) {
|
|
199
|
+
speeds.push(formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)));
|
|
200
|
+
}
|
|
201
|
+
else if (stats.ttftSteps > 0) {
|
|
202
|
+
speeds.push(`首字 ${formatDuration(stats.ttftMs / stats.ttftSteps)}`);
|
|
203
|
+
}
|
|
204
|
+
if (speeds.length > 0)
|
|
205
|
+
groups.push(speeds.join(' '));
|
|
206
|
+
const durations = [];
|
|
207
|
+
if (stats.llmMs > 0)
|
|
208
|
+
durations.push(`模型 ${formatDuration(stats.llmMs)}`);
|
|
209
|
+
if (stats.toolMs > 0)
|
|
210
|
+
durations.push(`工具 ${formatDuration(stats.toolMs)}`);
|
|
211
|
+
if (durations.length > 0)
|
|
212
|
+
groups.push(durations.join(' '));
|
|
213
|
+
if (billedInput > 0)
|
|
214
|
+
groups.push(`缓存命中 ${Math.round(stats.cacheReadTokens / billedInput * 100)}%`);
|
|
215
|
+
return groups;
|
|
216
|
+
}
|
|
217
|
+
export function fitFooterStatsLine(chip, groups, width) {
|
|
218
|
+
const kept = [...groups];
|
|
219
|
+
const render = () => kept.length === 0 ? chip : `${chip} │ ${kept.join(' │ ')}`;
|
|
220
|
+
while (kept.length > 0 && displayWidth(render()) > width)
|
|
221
|
+
kept.pop();
|
|
222
|
+
return truncateToWidth(render(), Math.max(1, width));
|
|
223
|
+
}
|
|
224
|
+
export function footerActivity(input) {
|
|
225
|
+
if (input.planReview)
|
|
226
|
+
return { kind: 'plan-review', text: '计划待审' };
|
|
227
|
+
if (input.waitingQuestion)
|
|
228
|
+
return { kind: 'waiting', text: '等待回答' };
|
|
229
|
+
if (input.compacting)
|
|
230
|
+
return { kind: 'compacting', text: '压缩中' };
|
|
231
|
+
if (input.retry !== undefined) {
|
|
232
|
+
return { kind: 'retry', text: `重试 ${input.retry.retry}/${input.retry.maxRetries}` };
|
|
233
|
+
}
|
|
234
|
+
if (input.subagents > 0)
|
|
235
|
+
return { kind: 'subagents', text: `子代理 ${input.subagents}` };
|
|
236
|
+
if (input.running && input.tools > 0)
|
|
237
|
+
return { kind: 'tools', text: `工具 ${input.tools}` };
|
|
238
|
+
if (input.planLeftOpen)
|
|
239
|
+
return { kind: 'plan-open', text: '本轮未收尾' };
|
|
240
|
+
if (input.planPending)
|
|
241
|
+
return { kind: 'plan-pending', text: '计划切换中' };
|
|
242
|
+
if (input.planActive)
|
|
243
|
+
return { kind: 'plan-pending', text: '计划模式' };
|
|
244
|
+
if (input.goalPhase === 'active')
|
|
245
|
+
return { kind: 'goal', text: '目标进行中' };
|
|
246
|
+
if (input.goalPhase === 'paused')
|
|
247
|
+
return { kind: 'goal', text: '目标已暂停' };
|
|
248
|
+
if (input.goalPhase === 'blocked')
|
|
249
|
+
return { kind: 'goal', text: '目标受阻' };
|
|
250
|
+
if (input.running && input.idleMs > WAIT_INDICATOR_MS) {
|
|
251
|
+
return { kind: 'waiting-llm', text: `等待 ${Math.floor(input.idleMs / 1000)}s` };
|
|
252
|
+
}
|
|
253
|
+
if (input.running)
|
|
254
|
+
return { kind: 'idle', text: '运行中' };
|
|
255
|
+
return { kind: 'idle', text: '空闲' };
|
|
256
|
+
}
|
|
257
|
+
/** Short remaining-quota bar: 8 pips, filled from the left. */
|
|
258
|
+
export function formatQuotaBar(remainingPercent, width = 8) {
|
|
259
|
+
const remaining = Math.max(0, Math.min(100, remainingPercent));
|
|
260
|
+
const filled = Math.round(remaining / 100 * width);
|
|
261
|
+
return `${'█'.repeat(filled)}${'░'.repeat(width - filled)}`;
|
|
262
|
+
}
|
|
263
|
+
export function footerIdentityParts(input) {
|
|
264
|
+
const parts = [];
|
|
265
|
+
if (input.preset !== undefined && input.preset !== '')
|
|
266
|
+
parts.push(`[${input.preset}]`);
|
|
267
|
+
const model = input.effort === undefined ? input.model : `${input.model} ${input.effort}`;
|
|
268
|
+
if (model !== '')
|
|
269
|
+
parts.push(model);
|
|
270
|
+
if (input.subDiffers)
|
|
271
|
+
parts.push(`sub:${input.subModel}`);
|
|
272
|
+
if (input.quotaCode !== undefined && input.quotaPercent !== undefined) {
|
|
273
|
+
parts.push(`${input.quotaCode} ${formatQuotaBar(input.quotaPercent)} ${input.quotaPercent.toFixed(0)}%`);
|
|
274
|
+
}
|
|
275
|
+
if (input.search !== undefined)
|
|
276
|
+
parts.push(`搜索 ${input.search.index + 1}/${input.search.total}`);
|
|
277
|
+
if (input.foldedInput)
|
|
278
|
+
parts.push('输入已折叠');
|
|
279
|
+
else if (input.multiLineInput)
|
|
280
|
+
parts.push('多行输入');
|
|
281
|
+
if (input.queued > 0)
|
|
282
|
+
parts.push(`排队 ${input.queued}`);
|
|
283
|
+
return parts;
|
|
284
|
+
}
|
|
285
|
+
export function fitFooterStatusLine(activity, identity, width) {
|
|
286
|
+
const kept = [...identity];
|
|
287
|
+
const render = () => kept.length === 0 ? activity : `${activity} ${kept.join(' · ')}`;
|
|
288
|
+
while (kept.length > 0 && displayWidth(render()) > width)
|
|
289
|
+
kept.pop();
|
|
290
|
+
return truncateToWidth(render(), Math.max(1, width));
|
|
291
|
+
}
|
|
106
292
|
/** One incremental paint as a single stdout write (one SSH packet when corked). */
|
|
107
293
|
export function composePaintOutput(options) {
|
|
108
294
|
const { width, height, paintRows, previousRows, sizeChanged, chromeChanged, chromeStart } = options;
|
|
295
|
+
const previousChromeStart = options.previousChromeStart ?? chromeStart;
|
|
296
|
+
// When a card expands, the input box moves up. Rows that used to be
|
|
297
|
+
// transcript may now be chrome (or vice versa); force-repaint from the
|
|
298
|
+
// higher of the two chrome starts so leftover tool-body glyphs cannot sit
|
|
299
|
+
// on the prompt.
|
|
300
|
+
const dirtyChromeStart = Math.min(chromeStart, previousChromeStart);
|
|
109
301
|
let out = '\x1b[?25l';
|
|
110
302
|
const prev = sizeChanged ? [] : previousRows;
|
|
111
303
|
if (sizeChanged)
|
|
@@ -115,7 +307,7 @@ export function composePaintOutput(options) {
|
|
|
115
307
|
const rowCount = Math.min(height, paintRows.length);
|
|
116
308
|
for (let i = 0; i < rowCount; i++) {
|
|
117
309
|
const current = paintRows[i] ?? '';
|
|
118
|
-
if (current === prev[i] && !(chromeChanged && i >=
|
|
310
|
+
if (current === prev[i] && !(chromeChanged && i >= dirtyChromeStart))
|
|
119
311
|
continue;
|
|
120
312
|
const clipped = padAnsiToWidth(current, width);
|
|
121
313
|
// EL2 *before* the glyphs, from column 1. A full-width write followed
|
|
@@ -296,7 +488,8 @@ export function providerUsesLocalOAuth(provider) {
|
|
|
296
488
|
}
|
|
297
489
|
const LOCAL_COMMANDS = [
|
|
298
490
|
{ name: 'help', description: 'show all available commands' },
|
|
299
|
-
{ name: 'model', description: 'select
|
|
491
|
+
{ name: 'model', description: 'select model and reasoning effort for the current provider' },
|
|
492
|
+
{ name: 'provider', description: 'switch provider, then model and reasoning effort' },
|
|
300
493
|
{ name: 'submodel', description: `select subagent model (default ${DEFAULT_SUBAGENT_MODEL}, same provider as parent)` },
|
|
301
494
|
{ name: 'subeffort', description: 'select subagent reasoning effort (default follows provider)' },
|
|
302
495
|
{ name: 'mode', description: 'switch agent mode / preset (standard, minimal, code, cordis, routing-suite, ...)' },
|
|
@@ -304,11 +497,11 @@ const LOCAL_COMMANDS = [
|
|
|
304
497
|
{ name: 'exit', description: 'exit the TUI' },
|
|
305
498
|
{ name: 'clear', description: 'clear the transcript view' },
|
|
306
499
|
{ name: 'status', description: 'show session, provider, model, paint, and plugin version' },
|
|
307
|
-
{ name: 'usage', description: 'show remaining quota for the current provider
|
|
308
|
-
{ name: '
|
|
500
|
+
{ name: 'usage', description: 'show remaining quota or account balance for the current provider' },
|
|
501
|
+
{ name: 'balance', description: 'alias of /usage: DeepSeek / OpenAI-compatible balance, or subscription quota' },
|
|
309
502
|
{ name: 'subagents', description: 'list active subagents; kill <id> to stop one' },
|
|
310
503
|
{ name: 'resume', description: 'resume a past session (empty = session picker)' },
|
|
311
|
-
{ name: 'setup', description: '
|
|
504
|
+
{ name: 'setup', description: 'add or update an API-key provider without wiping other saved routes' },
|
|
312
505
|
{ name: 'find', description: 'search thinking / plan / subagent / reply cards' },
|
|
313
506
|
{ name: 'dialog-test', description: 'verify the question dialog' },
|
|
314
507
|
];
|
|
@@ -905,6 +1098,14 @@ function reasoningEffortsForDefault(reasoning) {
|
|
|
905
1098
|
const OPENCODE_GO_USAGE_URL = 'https://opencode.ai/zen/go/v1/usage';
|
|
906
1099
|
const OPENCODE_ZEN_BASE_URL = 'https://opencode.ai/zen/v1';
|
|
907
1100
|
const SUPERGROK_BILLING_URL = 'https://cli-chat-proxy.grok.com/v1/billing?format=credits';
|
|
1101
|
+
const DEEPSEEK_PUBLIC_BASE_URL = 'https://api.deepseek.com';
|
|
1102
|
+
/** OpenAI-completions gateways: probe these relative to the configured base URL. */
|
|
1103
|
+
const OPENAI_COMPAT_BALANCE_PATHS = [
|
|
1104
|
+
'/user/balance',
|
|
1105
|
+
'/dashboard/billing/credit_grants',
|
|
1106
|
+
'/v1/dashboard/billing/credit_grants',
|
|
1107
|
+
'/v1/dashboard/billing/subscription',
|
|
1108
|
+
];
|
|
908
1109
|
const QUOTA_ALERT_THRESHOLDS = [50, 25, 10, 5];
|
|
909
1110
|
/** Remaining % at or below this is “close” and uses the faster cadence. */
|
|
910
1111
|
const QUOTA_NEAR_THRESHOLD_PERCENT = 55;
|
|
@@ -1124,6 +1325,112 @@ export function tightestQuotaWindow(snapshot) {
|
|
|
1124
1325
|
export function formatOpenCodeGoUsage(payload, source) {
|
|
1125
1326
|
return formatQuotaSnapshot(parseOpenCodeGoQuota(payload, source.provider));
|
|
1126
1327
|
}
|
|
1328
|
+
export function joinUrl(base, path) {
|
|
1329
|
+
const root = base.replace(/\/+$/u, '');
|
|
1330
|
+
const suffix = path.startsWith('/') ? path : `/${path}`;
|
|
1331
|
+
if (root.endsWith('/v1') && suffix.startsWith('/v1/'))
|
|
1332
|
+
return `${root}${suffix.slice(3)}`;
|
|
1333
|
+
return `${root}${suffix}`;
|
|
1334
|
+
}
|
|
1335
|
+
export function parseDeepSeekBalance(payload, provider = 'deepseek-official') {
|
|
1336
|
+
if (payload === null || typeof payload !== 'object') {
|
|
1337
|
+
throw new Error('DeepSeek 余额接口返回格式无法识别');
|
|
1338
|
+
}
|
|
1339
|
+
const raw = payload;
|
|
1340
|
+
const infos = Array.isArray(raw.balance_infos) ? raw.balance_infos : [];
|
|
1341
|
+
const lines = [];
|
|
1342
|
+
for (const item of infos) {
|
|
1343
|
+
if (item === null || typeof item !== 'object')
|
|
1344
|
+
continue;
|
|
1345
|
+
const row = item;
|
|
1346
|
+
const currency = typeof row.currency === 'string' ? row.currency : undefined;
|
|
1347
|
+
const total = typeof row.total_balance === 'string' ? row.total_balance : typeof row.total_balance === 'number' ? String(row.total_balance) : undefined;
|
|
1348
|
+
if (total === undefined)
|
|
1349
|
+
continue;
|
|
1350
|
+
lines.push({
|
|
1351
|
+
label: '可用余额',
|
|
1352
|
+
amount: total,
|
|
1353
|
+
...(currency === undefined ? {} : { currency }),
|
|
1354
|
+
});
|
|
1355
|
+
const granted = typeof row.granted_balance === 'string' ? row.granted_balance : undefined;
|
|
1356
|
+
const topped = typeof row.topped_up_balance === 'string' ? row.topped_up_balance : undefined;
|
|
1357
|
+
if (granted !== undefined)
|
|
1358
|
+
lines.push({ label: '赠送余额', amount: granted, ...(currency === undefined ? {} : { currency }) });
|
|
1359
|
+
if (topped !== undefined)
|
|
1360
|
+
lines.push({ label: '充值余额', amount: topped, ...(currency === undefined ? {} : { currency }) });
|
|
1361
|
+
}
|
|
1362
|
+
if (lines.length === 0)
|
|
1363
|
+
throw new Error('DeepSeek 余额接口返回格式无法识别');
|
|
1364
|
+
return {
|
|
1365
|
+
provider,
|
|
1366
|
+
plan: 'DeepSeek 官方',
|
|
1367
|
+
available: typeof raw.is_available === 'boolean' ? raw.is_available : undefined,
|
|
1368
|
+
lines,
|
|
1369
|
+
sourcePath: '/user/balance',
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
function numberish(value) {
|
|
1373
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
1374
|
+
return String(value);
|
|
1375
|
+
if (typeof value === 'string' && value.trim() !== '')
|
|
1376
|
+
return value.trim();
|
|
1377
|
+
return undefined;
|
|
1378
|
+
}
|
|
1379
|
+
function recordOf(value) {
|
|
1380
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
1381
|
+
? value
|
|
1382
|
+
: undefined;
|
|
1383
|
+
}
|
|
1384
|
+
/** Best-effort parse of OpenAI-compatible credit/balance JSON. */
|
|
1385
|
+
export function parseOpenAiCompatibleBalance(payload, provider, path) {
|
|
1386
|
+
const raw = recordOf(payload);
|
|
1387
|
+
if (raw === undefined)
|
|
1388
|
+
return undefined;
|
|
1389
|
+
const lines = [];
|
|
1390
|
+
const totalGranted = numberish(raw.total_granted);
|
|
1391
|
+
const totalUsed = numberish(raw.total_used);
|
|
1392
|
+
const totalAvailable = numberish(raw.total_available);
|
|
1393
|
+
if (totalAvailable !== undefined)
|
|
1394
|
+
lines.push({ label: '剩余额度', amount: totalAvailable, currency: 'USD' });
|
|
1395
|
+
if (totalGranted !== undefined)
|
|
1396
|
+
lines.push({ label: '总额度', amount: totalGranted, currency: 'USD' });
|
|
1397
|
+
if (totalUsed !== undefined)
|
|
1398
|
+
lines.push({ label: '已用', amount: totalUsed, currency: 'USD' });
|
|
1399
|
+
const hardLimit = numberish(raw.hard_limit_usd ?? raw.hard_limit);
|
|
1400
|
+
const softLimit = numberish(raw.soft_limit_usd ?? raw.soft_limit);
|
|
1401
|
+
if (hardLimit !== undefined)
|
|
1402
|
+
lines.push({ label: '硬限额', amount: hardLimit, currency: 'USD' });
|
|
1403
|
+
if (softLimit !== undefined)
|
|
1404
|
+
lines.push({ label: '软限额', amount: softLimit, currency: 'USD' });
|
|
1405
|
+
const data = recordOf(raw.data) ?? raw;
|
|
1406
|
+
const balance = numberish(data.balance ?? data.total_balance ?? data.credit ?? data.credits ?? data.quota);
|
|
1407
|
+
if (lines.length === 0 && balance !== undefined) {
|
|
1408
|
+
lines.push({ label: '余额', amount: balance, currency: typeof data.currency === 'string' ? data.currency : undefined });
|
|
1409
|
+
}
|
|
1410
|
+
if (Array.isArray(raw.balance_infos)) {
|
|
1411
|
+
try {
|
|
1412
|
+
return { ...parseDeepSeekBalance(raw, provider), plan: provider, sourcePath: path };
|
|
1413
|
+
}
|
|
1414
|
+
catch {
|
|
1415
|
+
// Not DeepSeek-shaped despite the field name.
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
if (lines.length === 0)
|
|
1419
|
+
return undefined;
|
|
1420
|
+
return { provider, plan: provider, lines, sourcePath: path };
|
|
1421
|
+
}
|
|
1422
|
+
export function formatAccountBalance(snapshot) {
|
|
1423
|
+
const header = [`${snapshot.plan} 余额(${snapshot.provider})`];
|
|
1424
|
+
if (snapshot.available === false)
|
|
1425
|
+
header.push('账号当前不可用');
|
|
1426
|
+
for (const line of snapshot.lines) {
|
|
1427
|
+
const currency = line.currency === undefined ? '' : ` ${line.currency}`;
|
|
1428
|
+
header.push(` ${line.label} · ${line.amount}${currency}`);
|
|
1429
|
+
}
|
|
1430
|
+
if (snapshot.sourcePath !== undefined)
|
|
1431
|
+
header.push(` 来源 ${snapshot.sourcePath}`);
|
|
1432
|
+
return header.join('\n');
|
|
1433
|
+
}
|
|
1127
1434
|
/** Extract a safe human-readable message from an OpenCode error payload. */
|
|
1128
1435
|
function openCodeApiErrorMessage(payload) {
|
|
1129
1436
|
if (typeof payload !== 'object' || payload === null)
|
|
@@ -1965,26 +2272,6 @@ export function parseExitStatus(text) {
|
|
|
1965
2272
|
}
|
|
1966
2273
|
return { body: text, exitCode: 0 };
|
|
1967
2274
|
}
|
|
1968
|
-
/** Compact token count, matching the web stats line (517 / 12.2K / 1.2M). */
|
|
1969
|
-
export function formatTokens(n) {
|
|
1970
|
-
const scaled = (value) => value >= 100 ? String(Math.round(value)) : String(Math.round(value * 10) / 10);
|
|
1971
|
-
if (n < 1_000)
|
|
1972
|
-
return String(n);
|
|
1973
|
-
if (n < 1_000_000)
|
|
1974
|
-
return `${scaled(n / 1_000)}K`;
|
|
1975
|
-
return `${scaled(n / 1_000_000)}M`;
|
|
1976
|
-
}
|
|
1977
|
-
/** Compact duration, matching the web stats line (45.2s / 2m42s). */
|
|
1978
|
-
export function formatDuration(ms) {
|
|
1979
|
-
const seconds = ms / 1_000;
|
|
1980
|
-
if (seconds < 60)
|
|
1981
|
-
return `${Math.round(seconds * 10) / 10}s`;
|
|
1982
|
-
const whole = Math.round(seconds);
|
|
1983
|
-
return `${Math.floor(whole / 60)}m${whole % 60}s`;
|
|
1984
|
-
}
|
|
1985
|
-
export function formatTokensPerSecond(tokensPerSecond) {
|
|
1986
|
-
return `${Math.round(tokensPerSecond)} tok/s`;
|
|
1987
|
-
}
|
|
1988
2275
|
const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
1989
2276
|
/** Owns one interactive terminal channel and its agent event wiring. */
|
|
1990
2277
|
export class SshTui {
|
|
@@ -2065,9 +2352,11 @@ export class SshTui {
|
|
|
2065
2352
|
lastChromeKey = '';
|
|
2066
2353
|
lastPaintWidth = 0;
|
|
2067
2354
|
lastPaintHeight = 0;
|
|
2355
|
+
lastChromeStart = 0;
|
|
2068
2356
|
paintIntervalMs;
|
|
2069
2357
|
paintLink = 'local';
|
|
2070
2358
|
paintProbed = false;
|
|
2359
|
+
paintRttMs;
|
|
2071
2360
|
sessionTitle = '';
|
|
2072
2361
|
llmRetry;
|
|
2073
2362
|
quotaSnapshot;
|
|
@@ -2142,8 +2431,8 @@ export class SshTui {
|
|
|
2142
2431
|
this.pushRow({ kind: 'error', text: `同步子代理模型失败: ${errorChain(error)}` });
|
|
2143
2432
|
this.markDirty();
|
|
2144
2433
|
});
|
|
2145
|
-
void this.refreshQuota({ reason: 'start', announce:
|
|
2146
|
-
// Start-up quota is
|
|
2434
|
+
void this.refreshQuota({ reason: 'start', announce: false }).catch(() => {
|
|
2435
|
+
// Start-up quota is silent; /usage and threshold alerts still report.
|
|
2147
2436
|
});
|
|
2148
2437
|
void this.notifyPluginUpdate().catch(() => {
|
|
2149
2438
|
// Update check is best-effort and never blocks the TUI.
|
|
@@ -2191,27 +2480,19 @@ export class SshTui {
|
|
|
2191
2480
|
const envOverride = Number.parseInt(process.env.DSH_TUI_PAINT_MS ?? '', 10);
|
|
2192
2481
|
if (Number.isFinite(envOverride) && envOverride > 0) {
|
|
2193
2482
|
this.paintProbed = false;
|
|
2194
|
-
this.
|
|
2195
|
-
kind: 'system',
|
|
2196
|
-
text: `${paintLinkLabel(this.paintLink, this.paintIntervalMs, false)} · DSH_TUI_PAINT_MS`,
|
|
2197
|
-
});
|
|
2483
|
+
this.markDirty();
|
|
2198
2484
|
return;
|
|
2199
2485
|
}
|
|
2200
2486
|
if (this.paintLink !== 'ssh') {
|
|
2201
|
-
this.
|
|
2487
|
+
this.markDirty();
|
|
2202
2488
|
return;
|
|
2203
2489
|
}
|
|
2204
2490
|
const rtt = await probeTerminalRttMs();
|
|
2205
2491
|
if (this.disposed)
|
|
2206
2492
|
return;
|
|
2207
2493
|
this.paintProbed = rtt !== undefined;
|
|
2494
|
+
this.paintRttMs = rtt;
|
|
2208
2495
|
this.paintIntervalMs = resolvePaintIntervalMs(undefined, {}, { ssh: true, rttMs: rtt });
|
|
2209
|
-
this.pushRow({
|
|
2210
|
-
kind: 'system',
|
|
2211
|
-
text: rtt === undefined
|
|
2212
|
-
? paintLinkLabel('ssh', this.paintIntervalMs, false)
|
|
2213
|
-
: `${paintLinkLabel('ssh', this.paintIntervalMs, true)} · 往返 ${Math.round(rtt)}ms`,
|
|
2214
|
-
});
|
|
2215
2496
|
this.markDirty();
|
|
2216
2497
|
}
|
|
2217
2498
|
/** Replay the durable session log so a resumed session renders its history. */
|
|
@@ -2238,7 +2519,7 @@ export class SshTui {
|
|
|
2238
2519
|
if (providerUsesLocalOAuth(provider)) {
|
|
2239
2520
|
this.pushRow({
|
|
2240
2521
|
kind: 'system',
|
|
2241
|
-
text: `当前是 ${describeProviderRoute(provider).kind}(${provider}),走本机 SuperGrok / X Premium OAuth,无需 API Key。用 /model 切换 Grok
|
|
2522
|
+
text: `当前是 ${describeProviderRoute(provider).kind}(${provider}),走本机 SuperGrok / X Premium OAuth,无需 API Key。用 /model 切换 Grok 模型和思考强度;换官方或 OpenCode 用 /provider。只有要新增 API Key 提供商时才需要 /setup。`,
|
|
2242
2523
|
});
|
|
2243
2524
|
this.markDirty();
|
|
2244
2525
|
return;
|
|
@@ -2740,8 +3021,9 @@ export class SshTui {
|
|
|
2740
3021
|
const displayRefs = [];
|
|
2741
3022
|
const searchHit = this.searchHits[this.searchIndex];
|
|
2742
3023
|
const addDisplay = (line, ref) => {
|
|
3024
|
+
const clipped = clipAnsiToWidth(line, width);
|
|
2743
3025
|
const hit = ref !== undefined && ref === searchHit;
|
|
2744
|
-
display.push(hit ? this.highlightSearchLine(
|
|
3026
|
+
display.push(hit ? this.highlightSearchLine(clipped) : clipped);
|
|
2745
3027
|
displayRefs.push(ref);
|
|
2746
3028
|
};
|
|
2747
3029
|
const pushRow = (kind, text, ref) => {
|
|
@@ -2818,7 +3100,7 @@ export class SshTui {
|
|
|
2818
3100
|
addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
|
|
2819
3101
|
continue;
|
|
2820
3102
|
}
|
|
2821
|
-
for (const wrapped of wrap(plainHeader
|
|
3103
|
+
for (const wrapped of wrap(`${focused ? '▶ ' : ' '}${plainHeader}`, width)) {
|
|
2822
3104
|
addDisplay(styleToolHeader(wrapped), row);
|
|
2823
3105
|
}
|
|
2824
3106
|
for (const line of toolBodyLines(row, this.maxToolOutputLines)) {
|
|
@@ -3210,66 +3492,76 @@ export class SshTui {
|
|
|
3210
3492
|
const dockTop = headerLines.length + visible.length + 1;
|
|
3211
3493
|
this.clickableRows.set(dockTop, dockPlan);
|
|
3212
3494
|
}
|
|
3213
|
-
const
|
|
3214
|
-
const
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
:
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3495
|
+
const linkChip = formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, this.color);
|
|
3496
|
+
const statsGroups = footerStatsGroups({
|
|
3497
|
+
turns: this.stats.turns,
|
|
3498
|
+
steps: this.stats.steps,
|
|
3499
|
+
llmMs: this.stats.llmMs,
|
|
3500
|
+
toolMs: this.stats.toolMs,
|
|
3501
|
+
ttftMs: this.stats.ttftMs,
|
|
3502
|
+
ttftSteps: this.stats.ttftSteps,
|
|
3503
|
+
decodeMs: this.stats.decodeMs,
|
|
3504
|
+
decodeTokens: this.stats.decodeTokens,
|
|
3505
|
+
inputTokens: this.stats.usage.inputTokens,
|
|
3506
|
+
outputTokens: this.stats.usage.outputTokens,
|
|
3507
|
+
cacheReadTokens: this.stats.usage.cacheReadTokens,
|
|
3508
|
+
cacheWriteTokens: this.stats.usage.cacheWriteTokens,
|
|
3509
|
+
});
|
|
3510
|
+
const statsPlain = fitFooterStatsLine(formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, false), statsGroups, Math.max(1, width));
|
|
3511
|
+
const chipVisible = formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, false);
|
|
3512
|
+
const statsLine = statsPlain.startsWith(chipVisible)
|
|
3513
|
+
? clipAnsiToWidth(`${linkChip}${this.styleLine('system', statsPlain.slice(chipVisible.length))}`, Math.max(1, width))
|
|
3514
|
+
: this.styleLine('system', statsPlain);
|
|
3232
3515
|
const idleMs = Date.now() - this.lastActivity;
|
|
3233
3516
|
const livePlan = this.findLivePlanRow();
|
|
3234
|
-
const waitingQuestions = this.rows.some(row => row.kind === 'question' && row.status === 'waiting');
|
|
3235
|
-
if (waitingQuestions || this.dialog?.kind === 'questions') {
|
|
3236
|
-
statusText += this.dialog?.kind === 'questions' && planReviewOf(this.dialog.question)
|
|
3237
|
-
? ' · 计划待审'
|
|
3238
|
-
: ' · 等待用户回答';
|
|
3239
|
-
}
|
|
3240
|
-
else if (livePlan?.turnLeftOpen === true) {
|
|
3241
|
-
statusText += ' · 本轮未收尾';
|
|
3242
|
-
}
|
|
3243
|
-
else if (livePlan?.active === true || livePlan?.pending === true) {
|
|
3244
|
-
statusText += livePlan.pending ? ' · 计划模式切换中' : ' · 计划模式';
|
|
3245
|
-
}
|
|
3246
3517
|
const liveGoal = this.rows.findLast((row) => row.kind === 'goal');
|
|
3247
|
-
if (liveGoal !== undefined && (liveGoal.phase === 'active' || liveGoal.phase === 'paused' || liveGoal.phase === 'blocked')) {
|
|
3248
|
-
const phase = liveGoal.phase === 'active' ? '目标进行中' : liveGoal.phase === 'paused' ? '目标已暂停' : '目标受阻';
|
|
3249
|
-
statusText += ` · ${phase}`;
|
|
3250
|
-
}
|
|
3251
|
-
const compacting = this.rows.some(row => row.kind === 'compaction' && row.status === 'running');
|
|
3252
|
-
if (compacting) {
|
|
3253
|
-
statusText += ` · ${this.spinnerFrame()} 压缩上下文`;
|
|
3254
|
-
}
|
|
3255
|
-
else if (this.activeSubagents.size > 0) {
|
|
3256
|
-
const spinner = this.spinnerFrame(160);
|
|
3257
|
-
statusText += ` · ${spinner} 子代理 ${this.activeSubagents.size}`;
|
|
3258
|
-
}
|
|
3259
|
-
else if (this.agent.status === 'running' && this.openToolCalls.size > 0) {
|
|
3260
|
-
statusText += ` · 工具执行中 ${this.openToolCalls.size}`;
|
|
3261
|
-
}
|
|
3262
|
-
else if (this.llmRetry !== undefined) {
|
|
3263
|
-
statusText += ` · 重试 ${this.llmRetry.retry}/${this.llmRetry.maxRetries}`;
|
|
3264
|
-
}
|
|
3265
|
-
else if (this.agent.status === 'running' && idleMs > WAIT_INDICATOR_MS) {
|
|
3266
|
-
statusText += ` · 等待响应 ${Math.floor(idleMs / 1000)}s`;
|
|
3267
|
-
}
|
|
3268
3518
|
const quotaWindow = this.quotaSnapshot === undefined ? undefined : tightestQuotaWindow(this.quotaSnapshot);
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
const
|
|
3519
|
+
const waitingQuestions = this.rows.some(row => row.kind === 'question' && row.status === 'waiting');
|
|
3520
|
+
const compacting = this.rows.some(row => row.kind === 'compaction' && row.status === 'running');
|
|
3521
|
+
const current = this.selectionRef?.current;
|
|
3522
|
+
const provider = this.currentProviderId();
|
|
3523
|
+
const parentModel = current?.model ?? this.agent.options.model ?? '';
|
|
3524
|
+
const sub = this.subagentSelection.current;
|
|
3525
|
+
const footer = {
|
|
3526
|
+
running: this.agent.status === 'running',
|
|
3527
|
+
planReview: this.dialog?.kind === 'questions' && planReviewOf(this.dialog.question),
|
|
3528
|
+
waitingQuestion: waitingQuestions || (this.dialog?.kind === 'questions' && !planReviewOf(this.dialog.question)),
|
|
3529
|
+
compacting,
|
|
3530
|
+
...(this.llmRetry === undefined ? {} : { retry: this.llmRetry }),
|
|
3531
|
+
subagents: this.activeSubagents.size,
|
|
3532
|
+
tools: this.openToolCalls.size,
|
|
3533
|
+
planLeftOpen: livePlan?.turnLeftOpen === true,
|
|
3534
|
+
planPending: livePlan?.pending === true,
|
|
3535
|
+
planActive: livePlan?.active === true,
|
|
3536
|
+
...(liveGoal?.phase === 'active' || liveGoal?.phase === 'paused' || liveGoal?.phase === 'blocked'
|
|
3537
|
+
? { goalPhase: liveGoal.phase }
|
|
3538
|
+
: {}),
|
|
3539
|
+
idleMs,
|
|
3540
|
+
model: parentModel,
|
|
3541
|
+
preset: this.presetName,
|
|
3542
|
+
...(current?.reasoningEffort === undefined ? {} : { effort: current.reasoningEffort }),
|
|
3543
|
+
provider,
|
|
3544
|
+
parentModel,
|
|
3545
|
+
subModel: sub.model,
|
|
3546
|
+
subDiffers: sub.model !== parentModel,
|
|
3547
|
+
...(quotaWindow === undefined || this.quotaSnapshot === undefined || this.quotaSnapshot.provider !== provider
|
|
3548
|
+
? {}
|
|
3549
|
+
: { quotaCode: this.quotaSnapshot.plan, quotaPercent: quotaWindow.remainingPercent }),
|
|
3550
|
+
...(this.searchHits.length > 0 && this.searchIndex >= 0
|
|
3551
|
+
? { search: { index: this.searchIndex, total: this.searchHits.length } }
|
|
3552
|
+
: {}),
|
|
3553
|
+
foldedInput: inputView.folded,
|
|
3554
|
+
multiLineInput: inputRows > 1,
|
|
3555
|
+
queued: this.pendingMessages.size,
|
|
3556
|
+
};
|
|
3557
|
+
const activity = footerActivity(footer);
|
|
3558
|
+
const activityText = activity.kind === 'compacting'
|
|
3559
|
+
? `${this.spinnerFrame()} ${activity.text}`
|
|
3560
|
+
: activity.kind === 'subagents'
|
|
3561
|
+
? `${this.spinnerFrame(160)} ${activity.text}`
|
|
3562
|
+
: activity.text;
|
|
3563
|
+
const statusText = fitFooterStatusLine(activityText, footerIdentityParts(footer), Math.max(1, width));
|
|
3564
|
+
const statusLine = this.styleLine('system', statusText);
|
|
3273
3565
|
const paintRows = [
|
|
3274
3566
|
...headerLines,
|
|
3275
3567
|
...visible,
|
|
@@ -3289,7 +3581,7 @@ export class SshTui {
|
|
|
3289
3581
|
this.status,
|
|
3290
3582
|
this.agent.status,
|
|
3291
3583
|
this.scrollOffset,
|
|
3292
|
-
|
|
3584
|
+
statsPlain,
|
|
3293
3585
|
statusText,
|
|
3294
3586
|
inputView.text,
|
|
3295
3587
|
inputView.folded,
|
|
@@ -3303,8 +3595,9 @@ export class SshTui {
|
|
|
3303
3595
|
this.activeSubagents.size,
|
|
3304
3596
|
this.dialog?.kind ?? '',
|
|
3305
3597
|
planDockLines.join('\n'),
|
|
3598
|
+
String(chromeStart),
|
|
3306
3599
|
].join('\x1f');
|
|
3307
|
-
const chromeChanged = chromeKey !== this.lastChromeKey;
|
|
3600
|
+
const chromeChanged = chromeKey !== this.lastChromeKey || chromeStart !== this.lastChromeStart;
|
|
3308
3601
|
const sizeChanged = width !== this.lastPaintWidth || height !== this.lastPaintHeight;
|
|
3309
3602
|
// One stdout write per frame: dirty rows only, so jump-host SSH sees a
|
|
3310
3603
|
// single packet instead of one write per line. Clip/pad so leftover
|
|
@@ -3319,6 +3612,7 @@ export class SshTui {
|
|
|
3319
3612
|
sizeChanged,
|
|
3320
3613
|
chromeChanged,
|
|
3321
3614
|
chromeStart,
|
|
3615
|
+
previousChromeStart: this.lastChromeStart,
|
|
3322
3616
|
cursorRow: row,
|
|
3323
3617
|
cursorColumn: column,
|
|
3324
3618
|
}));
|
|
@@ -3326,6 +3620,7 @@ export class SshTui {
|
|
|
3326
3620
|
this.lastChromeKey = chromeKey;
|
|
3327
3621
|
this.lastPaintWidth = width;
|
|
3328
3622
|
this.lastPaintHeight = height;
|
|
3623
|
+
this.lastChromeStart = chromeStart;
|
|
3329
3624
|
};
|
|
3330
3625
|
buildSuggestions() {
|
|
3331
3626
|
const input = this.input;
|
|
@@ -3379,36 +3674,22 @@ export class SshTui {
|
|
|
3379
3674
|
};
|
|
3380
3675
|
this.usageByStep.set(key, next);
|
|
3381
3676
|
}
|
|
3382
|
-
/**
|
|
3677
|
+
/** Compact session stats groups for the first footer row. */
|
|
3383
3678
|
statsText() {
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
if (stats.decodeMs > 0 && stats.decodeTokens > 0) {
|
|
3399
|
-
speeds.push(formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)));
|
|
3400
|
-
}
|
|
3401
|
-
if (speeds.length > 0)
|
|
3402
|
-
groups.push(speeds.join(' · '));
|
|
3403
|
-
const usage = stats.usage;
|
|
3404
|
-
const billedInput = usage.inputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
|
|
3405
|
-
if (billedInput > 0 || usage.outputTokens > 0) {
|
|
3406
|
-
if (billedInput > 0) {
|
|
3407
|
-
groups.push(`缓存命中 ${Math.round(usage.cacheReadTokens / billedInput * 100)}%`);
|
|
3408
|
-
}
|
|
3409
|
-
groups.push(`输入 ${formatTokens(billedInput)} · 输出 ${formatTokens(usage.outputTokens)}`);
|
|
3410
|
-
}
|
|
3411
|
-
return groups.join(' | ');
|
|
3679
|
+
return footerStatsGroups({
|
|
3680
|
+
turns: this.stats.turns,
|
|
3681
|
+
steps: this.stats.steps,
|
|
3682
|
+
llmMs: this.stats.llmMs,
|
|
3683
|
+
toolMs: this.stats.toolMs,
|
|
3684
|
+
ttftMs: this.stats.ttftMs,
|
|
3685
|
+
ttftSteps: this.stats.ttftSteps,
|
|
3686
|
+
decodeMs: this.stats.decodeMs,
|
|
3687
|
+
decodeTokens: this.stats.decodeTokens,
|
|
3688
|
+
inputTokens: this.stats.usage.inputTokens,
|
|
3689
|
+
outputTokens: this.stats.usage.outputTokens,
|
|
3690
|
+
cacheReadTokens: this.stats.usage.cacheReadTokens,
|
|
3691
|
+
cacheWriteTokens: this.stats.usage.cacheWriteTokens,
|
|
3692
|
+
}).join(' │ ');
|
|
3412
3693
|
}
|
|
3413
3694
|
/** Refresh the terminal window title (throttled while running). */
|
|
3414
3695
|
updateTerminalTitle() {
|
|
@@ -4556,50 +4837,24 @@ export class SshTui {
|
|
|
4556
4837
|
{ id: 'grok-4.5', label: 'Grok 4.5' },
|
|
4557
4838
|
{ id: 'grok-4.3', label: 'Grok 4.3' },
|
|
4558
4839
|
];
|
|
4559
|
-
|
|
4560
|
-
async runModelCommand() {
|
|
4840
|
+
async loadModelOptions(provider) {
|
|
4561
4841
|
const llm = this.ctx.get('llm');
|
|
4562
|
-
|
|
4563
|
-
|
|
4564
|
-
let provider = this.currentProviderId();
|
|
4565
|
-
const SWITCH_PROVIDER_ID = '__switch_provider__';
|
|
4566
|
-
const pickProvider = async () => {
|
|
4567
|
-
if (providers.length <= 1)
|
|
4568
|
-
return provider;
|
|
4569
|
-
const currentIndex = Math.max(0, providers.findIndex(option => option.id === provider));
|
|
4570
|
-
const pickedAnswer = await this.askQuestion({
|
|
4571
|
-
id: 'provider-pick',
|
|
4572
|
-
question: '选择提供商',
|
|
4573
|
-
options: providers.map(option => ({
|
|
4574
|
-
label: option.label,
|
|
4575
|
-
description: option.id === provider
|
|
4576
|
-
? `${describeProviderRoute(option.id).kind} · 当前`
|
|
4577
|
-
: describeProviderRoute(option.id).kind,
|
|
4578
|
-
})),
|
|
4579
|
-
}, 0, 1, currentIndex);
|
|
4580
|
-
return providers.find(option => option.label === pickedAnswer.selected[0])?.id;
|
|
4581
|
-
};
|
|
4582
|
-
let modelOptions = [];
|
|
4583
|
-
let modelSource = '已配置列表';
|
|
4584
|
-
// OpenCode and other third-party routes are interrogated live so the picker
|
|
4585
|
-
// shows what the endpoint actually serves, not just the stored catalog.
|
|
4842
|
+
let options = [];
|
|
4843
|
+
let source = '已配置列表';
|
|
4586
4844
|
if (this.piAiProviderProfile(provider) !== undefined || provider === 'opencode' || provider === 'opencode-go') {
|
|
4587
4845
|
const previousStatus = this.status;
|
|
4588
4846
|
try {
|
|
4589
4847
|
this.status = `正在从端点获取 ${provider} 的模型列表…`;
|
|
4590
4848
|
this.markDirty();
|
|
4591
|
-
|
|
4592
|
-
if (
|
|
4593
|
-
|
|
4594
|
-
// Keep models the endpoint does not list (e.g. ones already stored
|
|
4595
|
-
// for the route) selectable, so the live list never hides the
|
|
4596
|
-
// current model.
|
|
4849
|
+
options = await this.discoverEndpointModels(provider);
|
|
4850
|
+
if (options.length > 0) {
|
|
4851
|
+
source = '端点实时列表';
|
|
4597
4852
|
try {
|
|
4598
4853
|
const listed = (await llm?.listModels(provider)) ?? [];
|
|
4599
|
-
const endpointIds = new Set(
|
|
4854
|
+
const endpointIds = new Set(options.map(model => model.id));
|
|
4600
4855
|
for (const model of listed) {
|
|
4601
4856
|
if (!endpointIds.has(model.id)) {
|
|
4602
|
-
|
|
4857
|
+
options.push({ id: model.id, label: model.name || model.id });
|
|
4603
4858
|
}
|
|
4604
4859
|
}
|
|
4605
4860
|
}
|
|
@@ -4609,86 +4864,107 @@ export class SshTui {
|
|
|
4609
4864
|
}
|
|
4610
4865
|
}
|
|
4611
4866
|
catch {
|
|
4612
|
-
|
|
4867
|
+
options = [];
|
|
4613
4868
|
}
|
|
4614
4869
|
finally {
|
|
4615
4870
|
this.status = previousStatus;
|
|
4616
4871
|
this.markDirty();
|
|
4617
4872
|
}
|
|
4618
4873
|
}
|
|
4619
|
-
if (
|
|
4874
|
+
if (options.length === 0) {
|
|
4620
4875
|
try {
|
|
4621
4876
|
const listed = (await llm?.listModels(provider)) ?? [];
|
|
4622
|
-
|
|
4877
|
+
options = listed.map(model => ({ id: model.id, label: model.name || model.id }));
|
|
4623
4878
|
}
|
|
4624
4879
|
catch {
|
|
4625
|
-
|
|
4880
|
+
options = [];
|
|
4626
4881
|
}
|
|
4627
4882
|
}
|
|
4628
|
-
if (
|
|
4629
|
-
|
|
4630
|
-
|
|
4883
|
+
if (options.length === 0 && providerUsesLocalOAuth(provider)) {
|
|
4884
|
+
options = SshTui.XAI_FALLBACK_MODELS.map(option => ({ ...option }));
|
|
4885
|
+
source = 'SuperGrok 目录';
|
|
4631
4886
|
}
|
|
4632
|
-
if (
|
|
4633
|
-
const
|
|
4634
|
-
|
|
4887
|
+
if (options.length === 0) {
|
|
4888
|
+
const remembered = this.rememberedRoute(provider)?.model;
|
|
4889
|
+
const fallback = remembered
|
|
4890
|
+
?? (providerUsesLocalOAuth(provider) ? 'grok-4.6' : 'deepseek-v4-flash');
|
|
4891
|
+
options = [{ id: fallback, label: fallback }];
|
|
4635
4892
|
}
|
|
4893
|
+
return { options, source };
|
|
4894
|
+
}
|
|
4895
|
+
/** /model: models and effort for the current provider only. */
|
|
4896
|
+
async runModelCommand() {
|
|
4897
|
+
const provider = this.currentProviderId();
|
|
4898
|
+
const current = this.selectionRef?.current;
|
|
4899
|
+
const loaded = await this.loadModelOptions(provider);
|
|
4900
|
+
let modelOptions = loaded.options;
|
|
4636
4901
|
if (current?.model !== undefined && !modelOptions.some(option => option.id === current.model)) {
|
|
4637
4902
|
modelOptions = [{ id: current.model, label: current.model }, ...modelOptions];
|
|
4638
4903
|
}
|
|
4639
|
-
|
|
4640
|
-
modelOptions = [
|
|
4641
|
-
...modelOptions,
|
|
4642
|
-
{ id: SWITCH_PROVIDER_ID, label: '更换提供商…' },
|
|
4643
|
-
];
|
|
4644
|
-
}
|
|
4645
|
-
const selected = await this.pickModelOption(modelOptions, provider, modelSource, current?.model);
|
|
4904
|
+
const selected = await this.pickModelOption(modelOptions, provider, loaded.source, current?.model);
|
|
4646
4905
|
if (selected === undefined)
|
|
4647
4906
|
return;
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
|
|
4657
|
-
|
|
4658
|
-
modelOptions = await this.discoverEndpointModels(provider);
|
|
4659
|
-
if (modelOptions.length > 0)
|
|
4660
|
-
modelSource = '端点实时列表';
|
|
4661
|
-
}
|
|
4662
|
-
catch {
|
|
4663
|
-
modelOptions = [];
|
|
4664
|
-
}
|
|
4665
|
-
}
|
|
4666
|
-
if (modelOptions.length === 0) {
|
|
4667
|
-
try {
|
|
4668
|
-
const listed = (await llm?.listModels(provider)) ?? [];
|
|
4669
|
-
modelOptions = listed.map(model => ({ id: model.id, label: model.name || model.id }));
|
|
4670
|
-
}
|
|
4671
|
-
catch {
|
|
4672
|
-
modelOptions = [];
|
|
4673
|
-
}
|
|
4674
|
-
}
|
|
4675
|
-
if (modelOptions.length === 0 && providerUsesLocalOAuth(provider)) {
|
|
4676
|
-
modelOptions = SshTui.XAI_FALLBACK_MODELS.map(option => ({ ...option }));
|
|
4677
|
-
modelSource = 'SuperGrok 目录';
|
|
4678
|
-
}
|
|
4679
|
-
if (modelOptions.length === 0) {
|
|
4680
|
-
const fallback = providerUsesLocalOAuth(provider) ? 'grok-4.6' : 'deepseek-v4-flash';
|
|
4681
|
-
modelOptions = [{ id: fallback, label: fallback }];
|
|
4682
|
-
}
|
|
4683
|
-
const switched = await this.pickModelOption(modelOptions, provider, modelSource, undefined);
|
|
4684
|
-
if (switched === undefined)
|
|
4685
|
-
return;
|
|
4686
|
-
return await this.applyModelSelection(provider, switched.id, undefined);
|
|
4907
|
+
await this.applyModelSelection(provider, selected.id, modelOptions.map(option => option.id));
|
|
4908
|
+
}
|
|
4909
|
+
/** /provider: pick a provider, then its model (remembered route pre-filled). */
|
|
4910
|
+
async runProviderCommand() {
|
|
4911
|
+
const providers = this.listSelectableProviders();
|
|
4912
|
+
const current = this.currentProviderId();
|
|
4913
|
+
if (providers.length === 0) {
|
|
4914
|
+
this.pushRow({ kind: 'error', text: '没有可切换的提供商。用 /setup 先配置一条 API Key 路由。' });
|
|
4915
|
+
this.markDirty();
|
|
4916
|
+
return;
|
|
4687
4917
|
}
|
|
4688
|
-
|
|
4918
|
+
const currentIndex = Math.max(0, providers.findIndex(option => option.id === current));
|
|
4919
|
+
const pickedAnswer = await this.askQuestion({
|
|
4920
|
+
id: 'provider-pick',
|
|
4921
|
+
question: '选择提供商',
|
|
4922
|
+
options: providers.map(option => ({
|
|
4923
|
+
label: option.label,
|
|
4924
|
+
description: option.id === current
|
|
4925
|
+
? `${describeProviderRoute(option.id).kind} · 当前`
|
|
4926
|
+
: describeProviderRoute(option.id).kind,
|
|
4927
|
+
})),
|
|
4928
|
+
}, 0, 1, currentIndex);
|
|
4929
|
+
const provider = providers.find(option => option.label === pickedAnswer.selected[0])?.id;
|
|
4930
|
+
if (provider === undefined)
|
|
4931
|
+
return;
|
|
4932
|
+
const remembered = this.rememberedRoute(provider);
|
|
4933
|
+
const loaded = await this.loadModelOptions(provider);
|
|
4934
|
+
let modelOptions = loaded.options;
|
|
4935
|
+
if (remembered !== undefined && !modelOptions.some(option => option.id === remembered.model)) {
|
|
4936
|
+
modelOptions = [{ id: remembered.model, label: remembered.model }, ...modelOptions];
|
|
4937
|
+
}
|
|
4938
|
+
const selected = await this.pickModelOption(modelOptions, provider, loaded.source, remembered?.model ?? (provider === current ? this.selectionRef?.current?.model : undefined));
|
|
4939
|
+
if (selected === undefined)
|
|
4940
|
+
return;
|
|
4941
|
+
await this.applyModelSelection(provider, selected.id, modelOptions.map(option => option.id), remembered?.reasoningEffort);
|
|
4689
4942
|
}
|
|
4690
4943
|
/** Persist a provider/model/effort choice and keep the subagent on the same family. */
|
|
4691
|
-
|
|
4944
|
+
rememberedRoute(provider) {
|
|
4945
|
+
const section = this.ctx.get('settings')?.get(ROUTE_MEMORY_NS);
|
|
4946
|
+
const memory = section !== null && typeof section === 'object' && !Array.isArray(section)
|
|
4947
|
+
? parseRouteMemory(section.providers)
|
|
4948
|
+
: {};
|
|
4949
|
+
return rememberedRouteFor(memory, provider);
|
|
4950
|
+
}
|
|
4951
|
+
async rememberRoute(selection) {
|
|
4952
|
+
const settings = this.ctx.get('settings');
|
|
4953
|
+
if (settings === undefined)
|
|
4954
|
+
return;
|
|
4955
|
+
const section = settings.get(ROUTE_MEMORY_NS);
|
|
4956
|
+
const memory = section !== null && typeof section === 'object' && !Array.isArray(section)
|
|
4957
|
+
? parseRouteMemory(section.providers)
|
|
4958
|
+
: {};
|
|
4959
|
+
const next = upsertRememberedRoute(memory, selection.provider, {
|
|
4960
|
+
model: selection.model,
|
|
4961
|
+
...(selection.reasoningEffort === undefined ? {} : { reasoningEffort: String(selection.reasoningEffort) }),
|
|
4962
|
+
});
|
|
4963
|
+
await settings.mutate(ROUTE_MEMORY_NS, [
|
|
4964
|
+
{ op: 'set', path: ['providers'], value: next },
|
|
4965
|
+
]);
|
|
4966
|
+
}
|
|
4967
|
+
async applyModelSelection(provider, modelId, listed = [], preferredEffort) {
|
|
4692
4968
|
if (!(await this.ensureProviderModelConfigured(provider, modelId)))
|
|
4693
4969
|
return;
|
|
4694
4970
|
const llm = this.ctx.get('llm');
|
|
@@ -4719,7 +4995,10 @@ export class SshTui {
|
|
|
4719
4995
|
}
|
|
4720
4996
|
let effort;
|
|
4721
4997
|
if (effortOptions.length > 0) {
|
|
4722
|
-
const
|
|
4998
|
+
const rememberedEffort = this.rememberedRoute(provider)?.reasoningEffort ?? preferredEffort ?? '';
|
|
4999
|
+
const currentEffort = current?.provider === provider
|
|
5000
|
+
? String(current?.reasoningEffort ?? '')
|
|
5001
|
+
: rememberedEffort;
|
|
4723
5002
|
const currentIndex = Math.max(0, effortOptions.findIndex(option => option.id === currentEffort));
|
|
4724
5003
|
const effortAnswer = await this.askQuestion({
|
|
4725
5004
|
id: 'effort-pick',
|
|
@@ -4740,12 +5019,23 @@ export class SshTui {
|
|
|
4740
5019
|
this.selectionRef.current = next;
|
|
4741
5020
|
this.onSelectionChanged?.(next);
|
|
4742
5021
|
await this.ctx.get('agentDefaultModel')?.saveSelection(next);
|
|
5022
|
+
await this.rememberRoute(next);
|
|
4743
5023
|
const kind = describeProviderRoute(provider);
|
|
4744
5024
|
this.pushRow({
|
|
4745
5025
|
kind: 'system',
|
|
4746
5026
|
text: `已切换到 ${kind.kind}:${provider}/${modelId}(思考强度 ${effort ?? '默认'}${effortOptions.length === 0 ? ',该模型未声明可选强度' : ''});下一步请求生效。`,
|
|
4747
5027
|
});
|
|
4748
|
-
|
|
5028
|
+
const listedIds = listed.filter(id => id !== '__switch_provider__' && id !== '');
|
|
5029
|
+
const previousProvider = current?.provider ?? this.agent.options.provider ?? this.providerName;
|
|
5030
|
+
if (previousProvider !== provider) {
|
|
5031
|
+
await this.syncSubagentToProvider(provider, listedIds, true);
|
|
5032
|
+
await this.promptSubagentAfterProviderSwitch(provider);
|
|
5033
|
+
this.clearQuotaForProvider(provider);
|
|
5034
|
+
void this.refreshQuota({ reason: 'command', announce: false }).catch(() => { });
|
|
5035
|
+
}
|
|
5036
|
+
else {
|
|
5037
|
+
await this.syncSubagentToProvider(provider, listedIds);
|
|
5038
|
+
}
|
|
4749
5039
|
this.markDirty();
|
|
4750
5040
|
}
|
|
4751
5041
|
/** Provider route the next subagent request should use. */
|
|
@@ -4760,11 +5050,11 @@ export class SshTui {
|
|
|
4760
5050
|
* on a same-family model. An explicit leftover DeepSeek flash id after
|
|
4761
5051
|
* switching to xAI is treated as stale.
|
|
4762
5052
|
*/
|
|
4763
|
-
async syncSubagentToProvider(provider, listed = []) {
|
|
5053
|
+
async syncSubagentToProvider(provider, listed = [], force = false) {
|
|
4764
5054
|
const current = this.subagentSelection.current;
|
|
4765
|
-
if (current.provider !== undefined && current.provider !== provider)
|
|
5055
|
+
if (!force && current.provider !== undefined && current.provider !== provider)
|
|
4766
5056
|
return;
|
|
4767
|
-
if (subagentModelMatchesProvider(provider, current.model, listed))
|
|
5057
|
+
if (!force && subagentModelMatchesProvider(provider, current.model, listed))
|
|
4768
5058
|
return;
|
|
4769
5059
|
let catalog = [...listed];
|
|
4770
5060
|
if (catalog.length === 0) {
|
|
@@ -4777,10 +5067,9 @@ export class SshTui {
|
|
|
4777
5067
|
}
|
|
4778
5068
|
}
|
|
4779
5069
|
const nextModel = defaultSubagentModelForProvider(provider, catalog);
|
|
4780
|
-
if (nextModel === current.model)
|
|
5070
|
+
if (!force && nextModel === current.model && current.provider === undefined)
|
|
4781
5071
|
return;
|
|
4782
5072
|
const persisted = await this.saveSubagentSelection({
|
|
4783
|
-
...current,
|
|
4784
5073
|
model: nextModel,
|
|
4785
5074
|
reasoningEffort: undefined,
|
|
4786
5075
|
});
|
|
@@ -4789,6 +5078,43 @@ export class SshTui {
|
|
|
4789
5078
|
text: `子代理已跟随提供商 ${provider},模型改为 ${nextModel}${persisted ? '' : '(仅当前会话)'}。`,
|
|
4790
5079
|
});
|
|
4791
5080
|
}
|
|
5081
|
+
async promptSubagentAfterProviderSwitch(provider) {
|
|
5082
|
+
const current = this.subagentSelection.current;
|
|
5083
|
+
try {
|
|
5084
|
+
const { options, source } = await this.subagentModelOptions(provider);
|
|
5085
|
+
const listed = options.length === 0
|
|
5086
|
+
? [{ id: current.model, label: current.model }]
|
|
5087
|
+
: options;
|
|
5088
|
+
if (!listed.some(option => option.id === current.model)) {
|
|
5089
|
+
listed.unshift({ id: current.model, label: current.model });
|
|
5090
|
+
}
|
|
5091
|
+
const selected = await this.pickModelOption(listed, provider, source, current.model);
|
|
5092
|
+
if (selected === undefined || selected.id === current.model)
|
|
5093
|
+
return;
|
|
5094
|
+
if (!(await this.ensureProviderModelConfigured(provider, selected.id)))
|
|
5095
|
+
return;
|
|
5096
|
+
const persisted = await this.saveSubagentSelection({ model: selected.id });
|
|
5097
|
+
this.pushRow({
|
|
5098
|
+
kind: 'system',
|
|
5099
|
+
text: `子代理模型已设为 ${selected.id}(提供方跟随 ${provider})${persisted ? '' : '(仅当前会话)'}。`,
|
|
5100
|
+
});
|
|
5101
|
+
}
|
|
5102
|
+
catch (error) {
|
|
5103
|
+
if (error instanceof UserQuestionError) {
|
|
5104
|
+
this.pushRow({ kind: 'system', text: `子代理沿用 ${current.model}。之后可用 /submodel 再改。` });
|
|
5105
|
+
return;
|
|
5106
|
+
}
|
|
5107
|
+
this.pushRow({ kind: 'error', text: `选择子代理模型失败:${errorChain(error)}` });
|
|
5108
|
+
}
|
|
5109
|
+
}
|
|
5110
|
+
clearQuotaForProvider(provider) {
|
|
5111
|
+
if (this.quotaSnapshot !== undefined && this.quotaSnapshot.provider === provider)
|
|
5112
|
+
return;
|
|
5113
|
+
this.quotaSnapshot = undefined;
|
|
5114
|
+
this.quotaAlerted.clear();
|
|
5115
|
+
this.quotaTurnsSinceRefresh = 0;
|
|
5116
|
+
this.markDirty();
|
|
5117
|
+
}
|
|
4792
5118
|
/** Persist one subagent selection and publish it to the live request waterfall. */
|
|
4793
5119
|
async saveSubagentSelection(next) {
|
|
4794
5120
|
this.subagentSelection.current = next;
|
|
@@ -5090,30 +5416,35 @@ export class SshTui {
|
|
|
5090
5416
|
tokenLine,
|
|
5091
5417
|
].join('\n');
|
|
5092
5418
|
}
|
|
5093
|
-
/** /usage and /
|
|
5419
|
+
/** /usage and /balance: remaining quota or prepaid balance for the current provider. */
|
|
5094
5420
|
async runUsageCommand() {
|
|
5095
5421
|
const previousStatus = this.status;
|
|
5096
5422
|
this.status = '查询额度…';
|
|
5097
5423
|
this.markDirty();
|
|
5098
5424
|
try {
|
|
5099
|
-
const
|
|
5100
|
-
if (
|
|
5101
|
-
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
5105
|
-
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
|
|
5425
|
+
const quota = await this.refreshQuota({ reason: 'command', announce: true });
|
|
5426
|
+
if (quota !== undefined)
|
|
5427
|
+
return;
|
|
5428
|
+
const balance = await this.fetchAccountBalance(this.currentProviderId());
|
|
5429
|
+
if (balance !== undefined) {
|
|
5430
|
+
this.pushRow({ kind: 'system', text: formatAccountBalance(balance) });
|
|
5431
|
+
return;
|
|
5432
|
+
}
|
|
5433
|
+
const provider = this.currentProviderId();
|
|
5434
|
+
const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
|
|
5435
|
+
const source = openCodeSourceFor(provider, llmPiAi);
|
|
5436
|
+
if (source?.flavor === 'zen') {
|
|
5437
|
+
this.pushRow({ kind: 'system', text: this.zenUsageText(source) });
|
|
5438
|
+
}
|
|
5439
|
+
else {
|
|
5440
|
+
this.pushRow({
|
|
5441
|
+
kind: 'system',
|
|
5442
|
+
text: `当前提供商 ${provider} 没有可用的余额或额度接口。DeepSeek 官方走 /user/balance;OpenAI Completions 兼容网关会探测 credit_grants;OpenCode Go 与 SuperGrok 走订阅额度。`,
|
|
5443
|
+
});
|
|
5113
5444
|
}
|
|
5114
5445
|
}
|
|
5115
5446
|
catch (error) {
|
|
5116
|
-
this.pushRow({ kind: 'error', text: `/
|
|
5447
|
+
this.pushRow({ kind: 'error', text: `/balance failed: ${errorChain(error)}` });
|
|
5117
5448
|
}
|
|
5118
5449
|
finally {
|
|
5119
5450
|
this.status = previousStatus;
|
|
@@ -5144,14 +5475,66 @@ export class SshTui {
|
|
|
5144
5475
|
try {
|
|
5145
5476
|
const provider = this.currentProviderId();
|
|
5146
5477
|
const snapshot = await this.fetchQuotaSnapshot(provider);
|
|
5147
|
-
if (snapshot !== undefined)
|
|
5478
|
+
if (snapshot !== undefined) {
|
|
5148
5479
|
this.applyQuotaSnapshot(snapshot, options.announce);
|
|
5149
|
-
|
|
5480
|
+
return snapshot;
|
|
5481
|
+
}
|
|
5482
|
+
if (this.quotaSnapshot !== undefined && this.quotaSnapshot.provider !== provider) {
|
|
5483
|
+
this.quotaSnapshot = undefined;
|
|
5484
|
+
this.quotaAlerted.clear();
|
|
5485
|
+
this.markDirty();
|
|
5486
|
+
}
|
|
5487
|
+
return undefined;
|
|
5150
5488
|
}
|
|
5151
5489
|
finally {
|
|
5152
5490
|
this.quotaRefreshInFlight = false;
|
|
5153
5491
|
}
|
|
5154
5492
|
}
|
|
5493
|
+
async fetchAccountBalance(provider) {
|
|
5494
|
+
if (provider === 'deepseek-official' || provider === 'deepseek') {
|
|
5495
|
+
const apiKey = await this.resolveCredential('DEEPSEEK_API_KEY');
|
|
5496
|
+
if (apiKey === undefined)
|
|
5497
|
+
throw new Error('未找到 DEEPSEEK_API_KEY');
|
|
5498
|
+
const section = this.ctx.get('settings')?.get(settingsNamespace('llm-deepseek'));
|
|
5499
|
+
const baseURL = typeof section?.baseURL === 'string' && section.baseURL.trim() !== ''
|
|
5500
|
+
? section.baseURL.trim()
|
|
5501
|
+
: (process.env.DEEPSEEK_BASE_URL?.trim() || DEEPSEEK_PUBLIC_BASE_URL);
|
|
5502
|
+
const payload = await this.fetchJson(joinUrl(baseURL, '/user/balance'), {
|
|
5503
|
+
authorization: `Bearer ${apiKey}`,
|
|
5504
|
+
accept: 'application/json',
|
|
5505
|
+
}, 'DeepSeek');
|
|
5506
|
+
return parseDeepSeekBalance(payload, provider);
|
|
5507
|
+
}
|
|
5508
|
+
const profile = this.piAiProviderProfile(provider);
|
|
5509
|
+
const api = typeof profile?.api === 'string' ? profile.api : undefined;
|
|
5510
|
+
const baseURL = typeof profile?.baseURL === 'string' && profile.baseURL.trim() !== '' ? profile.baseURL.trim() : undefined;
|
|
5511
|
+
if (baseURL === undefined || (api !== undefined && api !== 'openai-completions'))
|
|
5512
|
+
return undefined;
|
|
5513
|
+
const apiKeyEnv = typeof profile?.apiKeyEnv === 'string' && profile.apiKeyEnv.trim() !== ''
|
|
5514
|
+
? profile.apiKeyEnv.trim()
|
|
5515
|
+
: `${provider.replaceAll('-', '_').toUpperCase()}_API_KEY`;
|
|
5516
|
+
const apiKey = await this.resolveCredential(apiKeyEnv);
|
|
5517
|
+
if (apiKey === undefined)
|
|
5518
|
+
throw new Error(`未找到凭据 ${apiKeyEnv}`);
|
|
5519
|
+
const errors = [];
|
|
5520
|
+
for (const path of OPENAI_COMPAT_BALANCE_PATHS) {
|
|
5521
|
+
const url = joinUrl(baseURL, path);
|
|
5522
|
+
try {
|
|
5523
|
+
const payload = await this.fetchJson(url, {
|
|
5524
|
+
authorization: `Bearer ${apiKey}`,
|
|
5525
|
+
accept: 'application/json',
|
|
5526
|
+
}, provider);
|
|
5527
|
+
const parsed = parseOpenAiCompatibleBalance(payload, provider, path);
|
|
5528
|
+
if (parsed !== undefined)
|
|
5529
|
+
return parsed;
|
|
5530
|
+
errors.push(`${path}: 返回无法识别`);
|
|
5531
|
+
}
|
|
5532
|
+
catch (error) {
|
|
5533
|
+
errors.push(`${path}: ${errorChain(error)}`);
|
|
5534
|
+
}
|
|
5535
|
+
}
|
|
5536
|
+
throw new Error(`OpenAI 兼容网关未找到余额接口(${errors.join(';')})`);
|
|
5537
|
+
}
|
|
5155
5538
|
async fetchQuotaSnapshot(provider) {
|
|
5156
5539
|
if (providerUsesLocalOAuth(provider)) {
|
|
5157
5540
|
const token = await this.resolveSuperGrokToken();
|
|
@@ -5818,6 +6201,7 @@ export class SshTui {
|
|
|
5818
6201
|
this.selectionRef.current = { provider: 'deepseek-official', model };
|
|
5819
6202
|
}
|
|
5820
6203
|
this.onSelectionChanged?.({ provider: 'deepseek-official', model });
|
|
6204
|
+
await this.rememberRoute({ provider: 'deepseek-official', model });
|
|
5821
6205
|
await this.syncSubagentToProvider('deepseek-official', state.models);
|
|
5822
6206
|
if (state.baseUrl !== '' && settings !== undefined) {
|
|
5823
6207
|
await settings.update(settingsNamespace('llm-deepseek'), { baseURL: state.baseUrl });
|
|
@@ -5826,7 +6210,7 @@ export class SshTui {
|
|
|
5826
6210
|
if (saved) {
|
|
5827
6211
|
this.pushRow({
|
|
5828
6212
|
kind: 'system',
|
|
5829
|
-
text:
|
|
6213
|
+
text: `配置完成,已记住 deepseek-official / ${model}。用 /provider 可切回其它已保存的提供商,无需再 /setup。`,
|
|
5830
6214
|
});
|
|
5831
6215
|
}
|
|
5832
6216
|
}
|
|
@@ -5845,12 +6229,37 @@ export class SshTui {
|
|
|
5845
6229
|
const reasoningEfforts = defaultEffort === undefined
|
|
5846
6230
|
? undefined
|
|
5847
6231
|
: { off: null, [defaultEffort]: defaultEffort };
|
|
6232
|
+
const existing = this.piAiProviderProfile(state.providerId);
|
|
6233
|
+
const existingModels = Array.isArray(existing?.models) ? existing.models : [];
|
|
6234
|
+
const mergedIds = [];
|
|
6235
|
+
const seen = new Set();
|
|
6236
|
+
for (const id of state.models) {
|
|
6237
|
+
if (id !== '' && !seen.has(id)) {
|
|
6238
|
+
seen.add(id);
|
|
6239
|
+
mergedIds.push(id);
|
|
6240
|
+
}
|
|
6241
|
+
}
|
|
6242
|
+
for (const raw of existingModels) {
|
|
6243
|
+
const id = typeof raw === 'string'
|
|
6244
|
+
? raw
|
|
6245
|
+
: typeof raw === 'object' && raw !== null && typeof raw.id === 'string'
|
|
6246
|
+
? raw.id
|
|
6247
|
+
: '';
|
|
6248
|
+
if (id !== '' && !seen.has(id)) {
|
|
6249
|
+
seen.add(id);
|
|
6250
|
+
mergedIds.push(id);
|
|
6251
|
+
}
|
|
6252
|
+
}
|
|
5848
6253
|
const profile = {
|
|
5849
|
-
displayName:
|
|
6254
|
+
displayName: typeof existing?.displayName === 'string' && existing.displayName.trim() !== ''
|
|
6255
|
+
? existing.displayName
|
|
6256
|
+
: template.label,
|
|
5850
6257
|
apiKeyEnv: envRef,
|
|
5851
|
-
api: template.api,
|
|
5852
|
-
baseURL: state.baseUrl === ''
|
|
5853
|
-
|
|
6258
|
+
api: template.api ?? existing?.api,
|
|
6259
|
+
baseURL: state.baseUrl === ''
|
|
6260
|
+
? (typeof existing?.baseURL === 'string' && existing.baseURL !== '' ? existing.baseURL : template.defaultBaseUrl)
|
|
6261
|
+
: state.baseUrl,
|
|
6262
|
+
models: mergedIds.map(id => ({
|
|
5854
6263
|
id,
|
|
5855
6264
|
...(reasoningEfforts === undefined ? {} : { reasoningEfforts }),
|
|
5856
6265
|
})),
|
|
@@ -5881,10 +6290,11 @@ export class SshTui {
|
|
|
5881
6290
|
this.selectionRef.current = selection;
|
|
5882
6291
|
}
|
|
5883
6292
|
this.onSelectionChanged?.(selection);
|
|
6293
|
+
await this.rememberRoute(selection);
|
|
5884
6294
|
await this.syncSubagentToProvider(state.providerId, state.models);
|
|
5885
6295
|
this.pushRow({
|
|
5886
6296
|
kind: 'system',
|
|
5887
|
-
text:
|
|
6297
|
+
text: `配置完成,已记住 ${state.providerId} / ${model}。其它提供商的模型和 Key 仍保留;用 /provider 切换,下一步请求生效。`,
|
|
5888
6298
|
});
|
|
5889
6299
|
}
|
|
5890
6300
|
}
|
|
@@ -6140,8 +6550,8 @@ export class SshTui {
|
|
|
6140
6550
|
'空输入时 ↑/↓ 选卡片(与 Ctrl+N/P 相同);Enter 展开;Ctrl+R 全部展开/收起;Ctrl+T 折叠输入。',
|
|
6141
6551
|
'Alt+1 最新思考 · Alt+2 计划 · Alt+3 子代理 · Alt+4 最新回复。',
|
|
6142
6552
|
'/find [思考|计划|子代理|回复] 关键字;Ctrl+/ 或 Alt+/ 打开搜索,Ctrl+G / Alt+N 下一条。',
|
|
6143
|
-
'/model
|
|
6144
|
-
'/setup
|
|
6553
|
+
'/model 只换当前提供商的模型和思考强度。/provider 换提供商(并选模型),下一步请求生效,无需重启。',
|
|
6554
|
+
'/setup 只新增或更新某一条 API Key 提供商,不会删掉其它已保存的路由。SuperGrok 走本机 OAuth,不需要填 Key。',
|
|
6145
6555
|
'/status 会标明当前是 DeepSeek 官方、SuperGrok 订阅、OpenCode Go / Zen,还是其它已注册提供商。',
|
|
6146
6556
|
].join('\n'),
|
|
6147
6557
|
});
|
|
@@ -6162,6 +6572,17 @@ export class SshTui {
|
|
|
6162
6572
|
this.markDirty();
|
|
6163
6573
|
});
|
|
6164
6574
|
break;
|
|
6575
|
+
case 'provider':
|
|
6576
|
+
void this.runProviderCommand().catch((error) => {
|
|
6577
|
+
if (error instanceof UserQuestionError) {
|
|
6578
|
+
this.pushRow({ kind: 'system', text: '提供商选择已取消。' });
|
|
6579
|
+
}
|
|
6580
|
+
else {
|
|
6581
|
+
this.pushRow({ kind: 'error', text: `/provider failed: ${errorChain(error)}` });
|
|
6582
|
+
}
|
|
6583
|
+
this.markDirty();
|
|
6584
|
+
});
|
|
6585
|
+
break;
|
|
6165
6586
|
case 'submodel':
|
|
6166
6587
|
void this.runSubmodelCommand(arg).catch((error) => {
|
|
6167
6588
|
if (error instanceof UserQuestionError) {
|
|
@@ -6228,13 +6649,14 @@ export class SshTui {
|
|
|
6228
6649
|
`preset: ${this.presetName}`,
|
|
6229
6650
|
`subagents: ${this.activeSubagents.size}`,
|
|
6230
6651
|
`plan: ${plan === undefined ? 'off' : plan.pending ? 'pending' : plan.active ? 'on' : 'off'}`,
|
|
6231
|
-
`paint: ${
|
|
6652
|
+
`paint: ${formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed)}`,
|
|
6232
6653
|
waiting > 0 ? `questions: waiting ${waiting}` : 'questions: none',
|
|
6233
6654
|
];
|
|
6234
6655
|
this.pushRow({ kind: 'system', text: lines.join('\n') });
|
|
6235
6656
|
}
|
|
6236
6657
|
break;
|
|
6237
6658
|
case 'usage':
|
|
6659
|
+
case 'balance':
|
|
6238
6660
|
case 'quota':
|
|
6239
6661
|
void this.runUsageCommand().catch((error) => {
|
|
6240
6662
|
this.pushRow({ kind: 'error', text: `/${command} failed: ${errorChain(error)}` });
|